init notify
This commit is contained in:
@ -1,54 +1,51 @@
|
||||
use gettextrs::gettext;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use adw::subclass::prelude::*;
|
||||
use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem};
|
||||
use futures::{AsyncRead, AsyncReadExt, AsyncWrite};
|
||||
use gettextrs::gettext;
|
||||
use gio::SocketClient;
|
||||
use gio::UnixSocketAddress;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{gdk, gio, glib};
|
||||
use ntfy_daemon::ntfy_capnp::system_notifier;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::config::{APP_ID, PKGDATADIR, PROFILE, VERSION};
|
||||
use crate::window::ExampleApplicationWindow;
|
||||
use crate::widgets::*;
|
||||
|
||||
trait RW: AsyncRead + AsyncWrite {}
|
||||
impl<T: AsyncRead + AsyncWrite> RW for T {}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use glib::WeakRef;
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExampleApplication {
|
||||
pub window: OnceCell<WeakRef<ExampleApplicationWindow>>,
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NotifyApplication {
|
||||
pub window: RefCell<WeakRef<NotifyWindow>>,
|
||||
pub hold_guard: OnceCell<gio::ApplicationHoldGuard>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for ExampleApplication {
|
||||
const NAME: &'static str = "ExampleApplication";
|
||||
type Type = super::ExampleApplication;
|
||||
impl ObjectSubclass for NotifyApplication {
|
||||
const NAME: &'static str = "NotifyApplication";
|
||||
type Type = super::NotifyApplication;
|
||||
type ParentType = adw::Application;
|
||||
}
|
||||
|
||||
impl ObjectImpl for ExampleApplication {}
|
||||
impl ObjectImpl for NotifyApplication {}
|
||||
|
||||
impl ApplicationImpl for ExampleApplication {
|
||||
impl ApplicationImpl for NotifyApplication {
|
||||
fn activate(&self) {
|
||||
debug!("AdwApplication<ExampleApplication>::activate");
|
||||
debug!("AdwApplication<NotifyApplication>::activate");
|
||||
self.parent_activate();
|
||||
let app = self.obj();
|
||||
|
||||
if let Some(window) = self.window.get() {
|
||||
let window = window.upgrade().unwrap();
|
||||
window.present();
|
||||
return;
|
||||
}
|
||||
|
||||
let window = ExampleApplicationWindow::new(&app);
|
||||
self.window
|
||||
.set(window.downgrade())
|
||||
.expect("Window already set.");
|
||||
|
||||
app.main_window().present();
|
||||
}
|
||||
|
||||
fn startup(&self) {
|
||||
debug!("AdwApplication<ExampleApplication>::startup");
|
||||
debug!("AdwApplication<NotifyApplication>::startup");
|
||||
self.parent_startup();
|
||||
let app = self.obj();
|
||||
|
||||
@ -59,21 +56,56 @@ mod imp {
|
||||
app.setup_gactions();
|
||||
app.setup_accels();
|
||||
}
|
||||
fn command_line(&self, command_line: &gio::ApplicationCommandLine) -> glib::ExitCode {
|
||||
let socket_path = glib::user_data_dir().join("com.ranfdev.Notify.socket");
|
||||
|
||||
debug!("AdwApplication<NotifyApplication>::command_line");
|
||||
let arguments = command_line.arguments();
|
||||
let is_daemon = arguments.get(1).map(|x| x.to_str()) == Some(Some("--daemon"));
|
||||
let app = self.obj();
|
||||
|
||||
if self.hold_guard.get().is_none() {
|
||||
self.obj().ensure_rpc_running(&socket_path);
|
||||
}
|
||||
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
super::NotifyApplication::run_in_background().await.unwrap();
|
||||
});
|
||||
|
||||
if is_daemon {
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
{
|
||||
let w = self.window.borrow();
|
||||
if let Some(window) = w.upgrade() {
|
||||
if window.is_visible() {
|
||||
window.present();
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.build_window(&socket_path);
|
||||
app.main_window().present();
|
||||
|
||||
glib::ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
impl GtkApplicationImpl for ExampleApplication {}
|
||||
impl AdwApplicationImpl for ExampleApplication {}
|
||||
impl GtkApplicationImpl for NotifyApplication {}
|
||||
impl AdwApplicationImpl for NotifyApplication {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct ExampleApplication(ObjectSubclass<imp::ExampleApplication>)
|
||||
pub struct NotifyApplication(ObjectSubclass<imp::NotifyApplication>)
|
||||
@extends gio::Application, gtk::Application,
|
||||
@implements gio::ActionMap, gio::ActionGroup;
|
||||
}
|
||||
|
||||
impl ExampleApplication {
|
||||
fn main_window(&self) -> ExampleApplicationWindow {
|
||||
self.imp().window.get().unwrap().upgrade().unwrap()
|
||||
impl NotifyApplication {
|
||||
fn main_window(&self) -> NotifyWindow {
|
||||
self.imp().window.borrow().upgrade().unwrap()
|
||||
}
|
||||
|
||||
fn setup_gactions(&self) {
|
||||
@ -105,7 +137,7 @@ impl ExampleApplication {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_resource("/com/ranfdev/Notify/style.css");
|
||||
if let Some(display) = gdk::Display::default() {
|
||||
gtk::StyleContext::add_provider_for_display(
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
@ -117,10 +149,7 @@ impl ExampleApplication {
|
||||
let dialog = adw::AboutWindow::builder()
|
||||
.application_icon(APP_ID)
|
||||
.application_name("Notify")
|
||||
// Insert your license of choice here
|
||||
// .license_type(gtk::License::MitX11)
|
||||
// Insert your website here
|
||||
// .website("https://gitlab.gnome.org/bilelmoussaoui/notify/")
|
||||
.license_type(gtk::License::Gpl30)
|
||||
.version(VERSION)
|
||||
.transient_for(&self.main_window())
|
||||
.translator_credits(gettext("translator-credits"))
|
||||
@ -133,18 +162,67 @@ impl ExampleApplication {
|
||||
}
|
||||
|
||||
pub fn run(&self) -> glib::ExitCode {
|
||||
info!("Notify ({})", APP_ID);
|
||||
info!("Version: {} ({})", VERSION, PROFILE);
|
||||
info!("Datadir: {}", PKGDATADIR);
|
||||
info!(app_id = %APP_ID, version = %VERSION, profile = %PROFILE, datadir = %PKGDATADIR, "running");
|
||||
|
||||
ApplicationExtManual::run(self)
|
||||
}
|
||||
async fn run_in_background() -> ashpd::Result<()> {
|
||||
let response = ashpd::desktop::background::Background::request()
|
||||
.reason("Listen for coming notifications")
|
||||
.auto_start(true)
|
||||
.command(&["notify", "--daemon"])
|
||||
.dbus_activatable(false)
|
||||
.send()
|
||||
.await?
|
||||
.response()?;
|
||||
|
||||
info!(auto_start = %response.auto_start(), run_in_background = %response.run_in_background());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_rpc_running(&self, socket_path: &Path) {
|
||||
let dbpath = glib::user_data_dir().join("com.ranfdev.Notify.sqlite");
|
||||
info!(database_path = %dbpath.display());
|
||||
ntfy_daemon::system_client::start(socket.to_owned(), dbpath.to_str().unwrap()).unwrap();
|
||||
self.imp().hold_guard.set(self.hold()).unwrap();
|
||||
}
|
||||
|
||||
fn build_window(&self, socket_path: &Path) {
|
||||
let address = UnixSocketAddress::new(socket_path);
|
||||
let client = SocketClient::new();
|
||||
let connection =
|
||||
SocketClientExt::connect(&client, &address, gio::Cancellable::NONE).unwrap();
|
||||
|
||||
let rw = connection.into_async_read_write().unwrap();
|
||||
let (reader, writer) = rw.split();
|
||||
|
||||
let rpc_network = Box::new(twoparty::VatNetwork::new(
|
||||
reader,
|
||||
writer,
|
||||
rpc_twoparty_capnp::Side::Client,
|
||||
Default::default(),
|
||||
));
|
||||
let mut rpc_system = RpcSystem::new(rpc_network, None);
|
||||
let client: system_notifier::Client =
|
||||
rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server);
|
||||
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
debug!("rpc_system started");
|
||||
rpc_system.await.unwrap();
|
||||
debug!("rpc_system stopped");
|
||||
});
|
||||
|
||||
let window = NotifyWindow::new(self, client);
|
||||
*self.imp().window.borrow_mut() = window.downgrade();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExampleApplication {
|
||||
impl Default for NotifyApplication {
|
||||
fn default() -> Self {
|
||||
glib::Object::builder()
|
||||
.property("application-id", APP_ID)
|
||||
.property("flags", gio::ApplicationFlags::HANDLES_COMMAND_LINE)
|
||||
.property("resource-base-path", "/com/ranfdev/Notify/")
|
||||
.build()
|
||||
}
|
||||
|
||||
28
src/async_utils.rs
Normal file
28
src/async_utils.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use glib::Receiver;
|
||||
use glib::SourceId;
|
||||
use gtk::glib;
|
||||
|
||||
pub fn debounce_channel<T: 'static>(
|
||||
duration: std::time::Duration,
|
||||
source: Receiver<T>,
|
||||
) -> Receiver<T> {
|
||||
let (tx, rx) = glib::MainContext::channel(Default::default());
|
||||
let scheduled = Rc::new(Cell::new(None::<SourceId>));
|
||||
source.attach(None, move |data| {
|
||||
if let Some(scheduled) = scheduled.take() {
|
||||
scheduled.remove();
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let scheduled_clone = scheduled.clone();
|
||||
let source_id = glib::source::timeout_add_local_once(duration, move || {
|
||||
tx.send(data).unwrap();
|
||||
scheduled_clone.take();
|
||||
});
|
||||
scheduled.set(Some(source_id));
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
rx
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
mod application;
|
||||
#[rustfmt::skip]
|
||||
mod config;
|
||||
mod window;
|
||||
mod async_utils;
|
||||
mod subscription;
|
||||
pub mod widgets;
|
||||
|
||||
use gettextrs::{gettext, LocaleCategory};
|
||||
use gtk::{gio, glib};
|
||||
|
||||
use self::application::ExampleApplication;
|
||||
use self::application::NotifyApplication;
|
||||
use self::config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE};
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
@ -23,6 +25,6 @@ fn main() -> glib::ExitCode {
|
||||
let res = gio::Resource::load(RESOURCES_FILE).expect("Could not load gresource file");
|
||||
gio::resources_register(&res);
|
||||
|
||||
let app = ExampleApplication::default();
|
||||
let app = NotifyApplication::default();
|
||||
app.run()
|
||||
}
|
||||
|
||||
318
src/subscription.rs
Normal file
318
src/subscription.rs
Normal file
@ -0,0 +1,318 @@
|
||||
use std::cell::{Cell, OnceCell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use adw::prelude::*;
|
||||
use capnp::capability::Promise;
|
||||
use capnp_rpc::pry;
|
||||
use glib::once_cell::sync::Lazy;
|
||||
use glib::subclass::prelude::*;
|
||||
use glib::subclass::Signal;
|
||||
use glib::Properties;
|
||||
use gtk::{gio, glib};
|
||||
use ntfy_daemon::models;
|
||||
use ntfy_daemon::ntfy_capnp::{output_channel, subscription, watch_handle, Status};
|
||||
use tracing::{debug, debug_span, error, instrument};
|
||||
|
||||
struct TopicWatcher {
|
||||
sub: glib::WeakRef<Subscription>,
|
||||
}
|
||||
impl output_channel::Server for TopicWatcher {
|
||||
fn send_message(
|
||||
&mut self,
|
||||
params: output_channel::SendMessageParams,
|
||||
_results: output_channel::SendMessageResults,
|
||||
) -> capnp::capability::Promise<(), capnp::Error> {
|
||||
if let Some(sub) = self.sub.upgrade() {
|
||||
let request = pry!(params.get());
|
||||
let message = pry!(request.get_message());
|
||||
|
||||
let msg: models::Message = serde_json::from_str(&message).unwrap();
|
||||
sub.imp().messages.append(&glib::BoxedAnyObject::new(msg));
|
||||
sub.update_unread_count();
|
||||
Promise::ok(())
|
||||
} else {
|
||||
Promise::err(capnp::Error::failed("dead channel".to_string()))
|
||||
}
|
||||
}
|
||||
fn send_status(
|
||||
&mut self,
|
||||
params: output_channel::SendStatusParams,
|
||||
_: output_channel::SendStatusResults,
|
||||
) -> capnp::capability::Promise<(), capnp::Error> {
|
||||
if let Some(sub) = self.sub.upgrade() {
|
||||
let status = pry!(pry!(params.get()).get_status());
|
||||
sub.imp().status.set(status);
|
||||
sub.notify_status();
|
||||
Promise::ok(())
|
||||
} else {
|
||||
Promise::err(capnp::Error::failed("dead channel".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TopicWatcher {
|
||||
fn drop(&mut self) {
|
||||
debug!("Dropped topic watcher");
|
||||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Properties)]
|
||||
#[properties(wrapper_type = super::Subscription)]
|
||||
pub struct Subscription {
|
||||
#[property(get)]
|
||||
pub display_name: RefCell<String>,
|
||||
#[property(get)]
|
||||
pub topic: RefCell<String>,
|
||||
#[property(get)]
|
||||
pub url: RefCell<String>,
|
||||
#[property(get)]
|
||||
pub server: RefCell<String>,
|
||||
#[property(get = Self::get_status, type = u8)]
|
||||
pub status: Rc<Cell<Status>>,
|
||||
#[property(get)]
|
||||
pub muted: Cell<bool>,
|
||||
#[property(get)]
|
||||
pub unread_count: Cell<u32>,
|
||||
pub read_until: Cell<u64>,
|
||||
pub messages: gio::ListStore,
|
||||
pub client: OnceCell<subscription::Client>,
|
||||
pub remote_handle: RefCell<Option<watch_handle::Client>>,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
fn get_status(&self) -> u8 {
|
||||
let s: u16 = Cell::get(&self.status).into();
|
||||
s as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Subscription {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_name: Default::default(),
|
||||
topic: Default::default(),
|
||||
url: Default::default(),
|
||||
muted: Default::default(),
|
||||
server: Default::default(),
|
||||
status: Rc::new(Cell::new(Status::Down)),
|
||||
messages: gio::ListStore::new::<glib::BoxedAnyObject>(),
|
||||
client: Default::default(),
|
||||
unread_count: Default::default(),
|
||||
read_until: Default::default(),
|
||||
remote_handle: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for Subscription {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: Lazy<Vec<Signal>> =
|
||||
Lazy::new(|| vec![Signal::builder("awarded").build()]);
|
||||
SIGNALS.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for Subscription {
|
||||
const NAME: &'static str = "TopicSubscription";
|
||||
type Type = super::Subscription;
|
||||
}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct Subscription(ObjectSubclass<imp::Subscription>);
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
pub fn new(client: subscription::Client) -> Self {
|
||||
let this: Self = glib::Object::builder().build();
|
||||
let imp = this.imp();
|
||||
if let Err(_) = imp.client.set(client) {
|
||||
panic!();
|
||||
};
|
||||
|
||||
let this_clone = this.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
match this_clone.load().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!(error = %e, "loading subscription data");
|
||||
}
|
||||
}
|
||||
});
|
||||
this
|
||||
}
|
||||
|
||||
fn init_info(
|
||||
&self,
|
||||
topic: &str,
|
||||
server: &str,
|
||||
muted: bool,
|
||||
read_until: u64,
|
||||
display_name: &str,
|
||||
) {
|
||||
let imp = self.imp();
|
||||
imp.topic.replace(topic.to_string());
|
||||
self.notify_topic();
|
||||
imp.server.replace(server.to_string());
|
||||
self.notify_server();
|
||||
imp.muted.replace(muted);
|
||||
self.notify_muted();
|
||||
imp.read_until.replace(read_until);
|
||||
self.notify_unread_count();
|
||||
self._set_display_name(display_name.to_string());
|
||||
}
|
||||
|
||||
fn load(&self) -> Promise<(), capnp::Error> {
|
||||
let imp = self.imp();
|
||||
let req_info = imp.client.get().unwrap().get_info_request();
|
||||
let req_messages = {
|
||||
let mut req = imp.client.get().unwrap().watch_request();
|
||||
req.get().set_watcher(capnp_rpc::new_client(TopicWatcher {
|
||||
sub: self.downgrade(),
|
||||
}));
|
||||
req
|
||||
};
|
||||
|
||||
let this = self.clone();
|
||||
Promise::from_future(async move {
|
||||
let info = req_info.send().promise.await?;
|
||||
let info = info.get()?;
|
||||
this.init_info(
|
||||
info.get_topic()?,
|
||||
info.get_server()?,
|
||||
info.get_muted(),
|
||||
info.get_read_until(),
|
||||
info.get_display_name()?,
|
||||
);
|
||||
|
||||
let message_stream = req_messages.send().promise.await?;
|
||||
let handle = message_stream.get()?.get_handle()?;
|
||||
this.imp().remote_handle.replace(Some(handle));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn _set_display_name(&self, value: String) {
|
||||
let imp = self.imp();
|
||||
let value = if value.is_empty() {
|
||||
self.topic()
|
||||
} else {
|
||||
value
|
||||
};
|
||||
imp.display_name.replace(value);
|
||||
self.notify_display_name();
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
pub fn set_display_name(&self, value: String) -> Promise<(), capnp::Error> {
|
||||
let this = self.clone();
|
||||
Promise::from_future(async move {
|
||||
this._set_display_name(value);
|
||||
this.send_updated_info().await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn send_updated_info(&self) -> Promise<(), capnp::Error> {
|
||||
let imp = self.imp();
|
||||
let mut req = imp.client.get().unwrap().update_info_request();
|
||||
let mut val = pry!(req.get().get_value());
|
||||
val.set_muted(imp.muted.get());
|
||||
val.set_display_name(&*imp.display_name.borrow());
|
||||
val.set_read_until(imp.read_until.get());
|
||||
Promise::from_future(async move {
|
||||
let _span = debug_span!("send_updated_info").entered();
|
||||
debug!("sending");
|
||||
req.send().promise.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
fn last_message(list: &gio::ListStore) -> Option<models::Message> {
|
||||
let n = list.n_items();
|
||||
let last = list
|
||||
.item(n.checked_sub(1)?)
|
||||
.and_downcast::<glib::BoxedAnyObject>()?;
|
||||
let last = last.borrow::<models::Message>();
|
||||
Some(last.clone())
|
||||
}
|
||||
fn update_unread_count(&self) {
|
||||
let imp = self.imp();
|
||||
if let Some(last) = Self::last_message(&imp.messages) {
|
||||
if last.time > imp.read_until.get() {
|
||||
imp.unread_count.set(1);
|
||||
} else {
|
||||
imp.unread_count.set(0);
|
||||
}
|
||||
} else {
|
||||
imp.unread_count.set(0);
|
||||
}
|
||||
self.notify_unread_count();
|
||||
}
|
||||
|
||||
pub fn set_muted(&self, value: bool) -> Promise<(), capnp::Error> {
|
||||
let this = self.clone();
|
||||
Promise::from_future(async move {
|
||||
this.imp().muted.replace(value);
|
||||
this.notify_muted();
|
||||
this.send_updated_info().await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
pub fn flag_all_as_read(&self) -> Promise<(), capnp::Error> {
|
||||
let imp = self.imp();
|
||||
let Some(last) = Self::last_message(&imp.messages) else {
|
||||
return Promise::ok(());
|
||||
};
|
||||
let value = last.time;
|
||||
|
||||
let this = self.clone();
|
||||
Promise::from_future(async move {
|
||||
let mut req = this.imp().client.get().unwrap().update_read_until_request();
|
||||
req.get().set_value(value);
|
||||
req.send().promise.await?;
|
||||
this.imp().read_until.set(value);
|
||||
this.update_unread_count();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
pub fn publish(&self, message: &str) -> Promise<(), capnp::Error> {
|
||||
let imp = self.imp();
|
||||
let mut req = imp.client.get().unwrap().publish_request();
|
||||
let msg = serde_json::to_string(&models::Message {
|
||||
topic: self.topic(),
|
||||
message: Some(message.to_string()),
|
||||
..models::Message::default()
|
||||
})
|
||||
.map_err(|e| capnp::Error::failed(e.to_string()));
|
||||
|
||||
req.get().set_message(&pry!(msg));
|
||||
|
||||
Promise::from_future(async move {
|
||||
let _span = debug_span!("publish").entered();
|
||||
debug!("sending");
|
||||
req.send().promise.await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
pub fn clear_notifications(&self) -> Promise<(), capnp::Error> {
|
||||
let imp = self.imp();
|
||||
let req = imp.client.get().unwrap().clear_notifications_request();
|
||||
let this = self.clone();
|
||||
Promise::from_future(async move {
|
||||
let _span = debug_span!("clear_notifications").entered();
|
||||
debug!("sending");
|
||||
req.send().promise.await?;
|
||||
this.imp().messages.remove_all();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn nice_status(&self) -> Status {
|
||||
Status::try_from(self.imp().status.get() as u16).unwrap()
|
||||
}
|
||||
}
|
||||
180
src/widgets/add_subscription_dialog.rs
Normal file
180
src/widgets/add_subscription_dialog.rs
Normal file
@ -0,0 +1,180 @@
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use glib::once_cell::sync::Lazy;
|
||||
use glib::subclass::Signal;
|
||||
use glib::Properties;
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
|
||||
mod imp {
|
||||
pub use super::*;
|
||||
#[derive(Debug, Default, Properties)]
|
||||
#[properties(wrapper_type = super::AddSubscriptionDialog)]
|
||||
pub struct AddSubscriptionDialog {
|
||||
#[property(name = "topic", get = |imp: &Self| imp.topic_entry.text(), type = glib::GString)]
|
||||
pub topic_entry: adw::EntryRow,
|
||||
#[property(name = "server", get = |imp: &Self| imp.server_entry.text(), type = glib::GString)]
|
||||
pub server_entry: adw::EntryRow,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for AddSubscriptionDialog {
|
||||
const NAME: &'static str = "AddSubscriptionDialog";
|
||||
type Type = super::AddSubscriptionDialog;
|
||||
type ParentType = adw::Window;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
klass.add_binding_action(
|
||||
gtk::gdk::Key::Escape,
|
||||
gtk::gdk::ModifierType::empty(),
|
||||
"window.close",
|
||||
None,
|
||||
);
|
||||
klass.install_action("default.activate", None, |this, _, _| {
|
||||
this.emit_subscribe_request();
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for AddSubscriptionDialog {
|
||||
fn signals() -> &'static [Signal] {
|
||||
static SIGNALS: Lazy<Vec<Signal>> =
|
||||
Lazy::new(|| vec![Signal::builder("subscribe-request").build()]);
|
||||
SIGNALS.as_ref()
|
||||
}
|
||||
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
let obj = self.obj().clone();
|
||||
obj.build_ui();
|
||||
}
|
||||
}
|
||||
impl WidgetImpl for AddSubscriptionDialog {}
|
||||
impl WindowImpl for AddSubscriptionDialog {}
|
||||
impl AdwWindowImpl for AddSubscriptionDialog {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct AddSubscriptionDialog(ObjectSubclass<imp::AddSubscriptionDialog>)
|
||||
@extends gtk::Widget, gtk::Window, adw::Window,
|
||||
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
|
||||
}
|
||||
|
||||
impl AddSubscriptionDialog {
|
||||
pub fn new() -> Self {
|
||||
glib::Object::builder().build()
|
||||
}
|
||||
fn build_ui(&self) {
|
||||
let imp = self.imp();
|
||||
let obj = self.clone();
|
||||
obj.set_title(Some("Subscribe To Topic"));
|
||||
obj.set_modal(true);
|
||||
obj.set_default_width(360);
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(12)
|
||||
.margin_end(12)
|
||||
.margin_start(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_child(Some(&content));
|
||||
|
||||
let description = {
|
||||
let d = gtk::Label::builder()
|
||||
.label("Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.")
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.build();
|
||||
d.add_css_class("dim-label");
|
||||
d
|
||||
};
|
||||
|
||||
content.append(&description);
|
||||
|
||||
let topic_entry = {
|
||||
let e = &imp.topic_entry;
|
||||
e.set_title("Topic");
|
||||
e.set_activates_default(true);
|
||||
|
||||
let rand_btn = {
|
||||
let b = gtk::Button::builder()
|
||||
.icon_name("dice3-symbolic")
|
||||
.tooltip_text("Generate Name")
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["flat"])
|
||||
.build();
|
||||
let ec = e.clone();
|
||||
b.connect_clicked(move |_| {
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
let mut rng = thread_rng();
|
||||
let chars: String = (0..10).map(|_| rng.sample(Alphanumeric) as char).collect();
|
||||
ec.set_text(&chars);
|
||||
});
|
||||
b
|
||||
};
|
||||
|
||||
e.add_suffix(&rand_btn);
|
||||
e
|
||||
};
|
||||
// TODO: Reserved topics
|
||||
/*let reserved_switch = {
|
||||
adw::SwitchRow::builder()
|
||||
.title("Reserved")
|
||||
.subtitle("For Ntfy Pro users only")
|
||||
.build()
|
||||
};*/
|
||||
let server_entry = &imp.server_entry;
|
||||
server_entry.set_title("Server");
|
||||
|
||||
let expander_row = {
|
||||
let e = adw::ExpanderRow::builder()
|
||||
.title("Custom Server...")
|
||||
.enable_expansion(false)
|
||||
.show_enable_switch(true)
|
||||
.build();
|
||||
e.add_row(server_entry);
|
||||
e
|
||||
};
|
||||
let list_box = {
|
||||
let l = gtk::ListBox::new();
|
||||
l.add_css_class("boxed-list");
|
||||
l.append(topic_entry);
|
||||
// l.append(&reserved_switch);
|
||||
l.append(&expander_row);
|
||||
l
|
||||
};
|
||||
content.append(&list_box);
|
||||
|
||||
let sub_btn = {
|
||||
let b = gtk::Button::new();
|
||||
b.set_label("Subscribe");
|
||||
b.add_css_class("suggested-action");
|
||||
b.add_css_class("pill");
|
||||
b.set_halign(gtk::Align::Center);
|
||||
|
||||
let wc = obj.clone();
|
||||
b.connect_clicked(move |_| {
|
||||
wc.emit_subscribe_request();
|
||||
wc.close();
|
||||
});
|
||||
b
|
||||
};
|
||||
|
||||
content.append(&sub_btn);
|
||||
toolbar_view.set_content(Some(&clamp));
|
||||
|
||||
obj.set_content(Some(&toolbar_view));
|
||||
}
|
||||
fn emit_subscribe_request(&self) {
|
||||
self.emit_by_name::<()>("subscribe-request", &[]);
|
||||
}
|
||||
}
|
||||
209
src/widgets/message_row.rs
Normal file
209
src/widgets/message_row.rs
Normal file
@ -0,0 +1,209 @@
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use gtk::{gio, glib};
|
||||
use ntfy_daemon::models;
|
||||
use tracing::error;
|
||||
|
||||
use crate::widgets::*;
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageRow {}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for MessageRow {
|
||||
const NAME: &'static str = "MessageRow";
|
||||
type Type = super::MessageRow;
|
||||
type ParentType = adw::Bin;
|
||||
}
|
||||
|
||||
impl ObjectImpl for MessageRow {}
|
||||
|
||||
impl WidgetImpl for MessageRow {}
|
||||
impl BinImpl for MessageRow {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct MessageRow(ObjectSubclass<imp::MessageRow>)
|
||||
@extends gtk::Widget, adw::Bin;
|
||||
}
|
||||
|
||||
impl MessageRow {
|
||||
pub fn new(msg: models::Message) -> Self {
|
||||
let this: Self = glib::Object::new();
|
||||
this.build_ui(msg);
|
||||
this
|
||||
}
|
||||
fn build_ui(&self, msg: models::Message) {
|
||||
let top_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
|
||||
let time = gtk::Label::builder()
|
||||
.label(
|
||||
&NaiveDateTime::from_timestamp_opt(msg.time as i64, 0)
|
||||
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.build();
|
||||
time.add_css_class("caption");
|
||||
|
||||
top_box.append(&time);
|
||||
|
||||
if let Some(p) = msg.priority {
|
||||
let text = format!(
|
||||
"Priority: {}",
|
||||
match p {
|
||||
5 => "Max",
|
||||
4 => "High",
|
||||
3 => "Medium",
|
||||
2 => "Low",
|
||||
1 => "Min",
|
||||
_ => "Invalid",
|
||||
}
|
||||
);
|
||||
let priority = gtk::Label::builder()
|
||||
.label(&text)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.build();
|
||||
priority.add_css_class("caption");
|
||||
priority.add_css_class("chip");
|
||||
if p == 5 {
|
||||
priority.add_css_class("chip--danger")
|
||||
} else if p == 4 {
|
||||
priority.add_css_class("chip--warning")
|
||||
}
|
||||
top_box.append(&priority);
|
||||
}
|
||||
|
||||
let b = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
|
||||
b.append(&top_box);
|
||||
if let Some(title) = msg.display_title() {
|
||||
let label = gtk::Label::builder()
|
||||
.label(&title)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.selectable(true)
|
||||
.build();
|
||||
label.add_css_class("heading");
|
||||
b.append(&label);
|
||||
}
|
||||
|
||||
if let Some(message) = msg.display_message() {
|
||||
let label = gtk::Label::builder()
|
||||
.label(&message)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.selectable(true)
|
||||
.build();
|
||||
b.append(&label);
|
||||
}
|
||||
|
||||
if msg.actions.len() > 0 {
|
||||
let action_btns = gtk::Box::builder().spacing(8).build();
|
||||
|
||||
for a in msg.actions {
|
||||
let btn = self.build_action_btn(a);
|
||||
action_btns.append(&btn);
|
||||
}
|
||||
|
||||
b.append(&action_btns);
|
||||
}
|
||||
if msg.tags.len() > 0 {
|
||||
let mut tags_text = String::from("tags: ");
|
||||
tags_text.push_str(&msg.tags.join(", "));
|
||||
let tags = gtk::Label::builder()
|
||||
.label(&tags_text)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.build();
|
||||
b.append(&tags);
|
||||
}
|
||||
|
||||
self.set_child(Some(&b));
|
||||
}
|
||||
fn build_action_btn(&self, action: models::Action) -> gtk::Button {
|
||||
let btn = gtk::Button::new();
|
||||
match action {
|
||||
models::Action::View { label, url, .. } => {
|
||||
btn.set_label(&label);
|
||||
btn.set_tooltip_text(Some(&format!("Go to {url}")));
|
||||
btn.connect_clicked(move |_| {
|
||||
gtk::UriLauncher::builder().uri(url.clone()).build().launch(
|
||||
gtk::Window::NONE,
|
||||
gio::Cancellable::NONE,
|
||||
|_| {},
|
||||
);
|
||||
});
|
||||
}
|
||||
models::Action::Http {
|
||||
label,
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
..
|
||||
} => {
|
||||
btn.set_label(&label);
|
||||
btn.set_tooltip_text(Some(&format!("Send HTTP {method} to {url}")));
|
||||
let (tx, rx) = glib::MainContext::channel(Default::default());
|
||||
let this = self.clone();
|
||||
btn.connect_clicked({
|
||||
let url = url.clone();
|
||||
let method = method.clone();
|
||||
move |_| {
|
||||
let url = url.clone();
|
||||
let method = method.clone();
|
||||
let tx = tx.clone();
|
||||
let body = body.clone();
|
||||
let headers = headers.clone();
|
||||
gio::spawn_blocking(move || {
|
||||
let mut req = ureq::request(method.as_str(), url.as_str());
|
||||
for (k, v) in headers.iter() {
|
||||
req = req.set(&k, &v);
|
||||
}
|
||||
tx.send(req.send(body.as_bytes())).unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
rx.attach(Some(&glib::MainContext::default()), move |res| {
|
||||
let method = method.clone();
|
||||
let url = url.clone();
|
||||
this.spawn_with_near_toast(async move {
|
||||
match res {
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Error sending request");
|
||||
Err(format!("Error sending HTTP {method} to {url}"))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
}
|
||||
});
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
models::Action::Broadcast { label, .. } => {
|
||||
btn.set_label(&label);
|
||||
btn.set_sensitive(false);
|
||||
btn.set_tooltip_text(Some("Broadcast action only available on Android"));
|
||||
}
|
||||
}
|
||||
btn
|
||||
}
|
||||
}
|
||||
8
src/widgets/mod.rs
Normal file
8
src/widgets/mod.rs
Normal file
@ -0,0 +1,8 @@
|
||||
mod add_subscription_dialog;
|
||||
mod message_row;
|
||||
mod subscription_info_dialog;
|
||||
mod window;
|
||||
pub use add_subscription_dialog::AddSubscriptionDialog;
|
||||
pub use message_row::*;
|
||||
pub use subscription_info_dialog::SubscriptionInfoDialog;
|
||||
pub use window::*;
|
||||
112
src/widgets/subscription_info_dialog.rs
Normal file
112
src/widgets/subscription_info_dialog.rs
Normal file
@ -0,0 +1,112 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use glib::Properties;
|
||||
use gtk::gio;
|
||||
use gtk::glib;
|
||||
|
||||
use crate::widgets::*;
|
||||
|
||||
mod imp {
|
||||
pub use super::*;
|
||||
#[derive(Debug, Default, Properties, gtk::CompositeTemplate)]
|
||||
#[template(resource = "/com/ranfdev/Notify/ui/subscription_info_dialog.ui")]
|
||||
#[properties(wrapper_type = super::SubscriptionInfoDialog)]
|
||||
pub struct SubscriptionInfoDialog {
|
||||
#[property(get, construct_only)]
|
||||
pub subscription: RefCell<Option<crate::subscription::Subscription>>,
|
||||
#[template_child]
|
||||
pub display_name_entry: TemplateChild<adw::EntryRow>,
|
||||
#[template_child]
|
||||
pub muted_switch_row: TemplateChild<adw::SwitchRow>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for SubscriptionInfoDialog {
|
||||
const NAME: &'static str = "SubscriptionInfoDialog";
|
||||
type Type = super::SubscriptionInfoDialog;
|
||||
type ParentType = adw::Window;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
klass.bind_template();
|
||||
klass.add_binding_action(
|
||||
gtk::gdk::Key::Escape,
|
||||
gtk::gdk::ModifierType::empty(),
|
||||
"window.close",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// You must call `Widget`'s `init_template()` within `instance_init()`.
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
#[glib::derived_properties]
|
||||
impl ObjectImpl for SubscriptionInfoDialog {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
let this = self.obj().clone();
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::Priority::default());
|
||||
let rx =
|
||||
crate::async_utils::debounce_channel(std::time::Duration::from_millis(500), rx);
|
||||
rx.attach(None, move |entry| {
|
||||
this.update_display_name(&entry);
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
let this = self.obj().clone();
|
||||
self.display_name_entry
|
||||
.set_text(&this.subscription().unwrap().display_name());
|
||||
self.muted_switch_row
|
||||
.set_active(this.subscription().unwrap().muted());
|
||||
|
||||
self.display_name_entry.connect_changed({
|
||||
move |entry| {
|
||||
tx.send(entry.clone()).unwrap();
|
||||
}
|
||||
});
|
||||
let this = self.obj().clone();
|
||||
self.muted_switch_row.connect_active_notify({
|
||||
move |switch| {
|
||||
this.update_muted(switch);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl WidgetImpl for SubscriptionInfoDialog {}
|
||||
impl WindowImpl for SubscriptionInfoDialog {}
|
||||
impl AdwWindowImpl for SubscriptionInfoDialog {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct SubscriptionInfoDialog(ObjectSubclass<imp::SubscriptionInfoDialog>)
|
||||
@extends gtk::Widget, gtk::Window, adw::Window,
|
||||
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
|
||||
}
|
||||
|
||||
impl SubscriptionInfoDialog {
|
||||
pub fn new(subscription: crate::subscription::Subscription) -> Self {
|
||||
let this = glib::Object::builder()
|
||||
.property("subscription", subscription)
|
||||
.build();
|
||||
this
|
||||
}
|
||||
fn update_display_name(&self, entry: &impl IsA<gtk::Editable>) {
|
||||
if let Some(sub) = self.subscription() {
|
||||
let entry = entry.clone();
|
||||
self.spawn_with_near_toast(async move {
|
||||
let res = sub.set_display_name(entry.text().to_string()).await;
|
||||
res
|
||||
});
|
||||
}
|
||||
}
|
||||
fn update_muted(&self, switch: &adw::SwitchRow) {
|
||||
if let Some(sub) = self.subscription() {
|
||||
let switch = switch.clone();
|
||||
self.spawn_with_near_toast(async move { sub.set_muted(switch.is_active()).await })
|
||||
}
|
||||
}
|
||||
}
|
||||
483
src/widgets/window.rs
Normal file
483
src/widgets/window.rs
Normal file
@ -0,0 +1,483 @@
|
||||
use std::cell::Cell;
|
||||
use std::cell::OnceCell;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use futures::prelude::*;
|
||||
use gtk::{gio, glib};
|
||||
use ntfy_daemon::models;
|
||||
use ntfy_daemon::ntfy_capnp::{system_notifier, Status};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::application::NotifyApplication;
|
||||
use crate::config::{APP_ID, PROFILE};
|
||||
use crate::subscription::Subscription;
|
||||
use crate::widgets::*;
|
||||
|
||||
pub trait SpawnWithToast {
|
||||
fn spawn_with_near_toast<T, R: std::fmt::Display>(
|
||||
&self,
|
||||
f: impl Future<Output = Result<T, R>> + 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl<W: glib::IsA<gtk::Widget>> SpawnWithToast for W {
|
||||
fn spawn_with_near_toast<T, R: std::fmt::Display>(
|
||||
&self,
|
||||
f: impl Future<Output = Result<T, R>> + 'static,
|
||||
) {
|
||||
let p: Option<NotifyWindow> = self.ancestor(NotifyWindow::static_type()).and_downcast();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
if let Err(e) = f.await {
|
||||
if let Some(p) = p {
|
||||
p.imp()
|
||||
.toast_overlay
|
||||
.add_toast(adw::Toast::builder().title(&e.to_string()).build())
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(gtk::CompositeTemplate)]
|
||||
#[template(resource = "/com/ranfdev/Notify/ui/window.ui")]
|
||||
pub struct NotifyWindow {
|
||||
#[template_child]
|
||||
pub headerbar: TemplateChild<adw::HeaderBar>,
|
||||
#[template_child]
|
||||
pub message_list: TemplateChild<gtk::ListBox>,
|
||||
#[template_child]
|
||||
pub subscription_list: TemplateChild<gtk::ListBox>,
|
||||
#[template_child]
|
||||
pub entry: TemplateChild<gtk::Entry>,
|
||||
#[template_child]
|
||||
pub navigation_split_view: TemplateChild<adw::NavigationSplitView>,
|
||||
#[template_child]
|
||||
pub subscription_view: TemplateChild<adw::ToolbarView>,
|
||||
#[template_child]
|
||||
pub subscription_menu_btn: TemplateChild<gtk::MenuButton>,
|
||||
pub subscription_list_model: gio::ListStore,
|
||||
#[template_child]
|
||||
pub toast_overlay: TemplateChild<adw::ToastOverlay>,
|
||||
#[template_child]
|
||||
pub stack: TemplateChild<gtk::Stack>,
|
||||
#[template_child]
|
||||
pub welcome_view: TemplateChild<adw::StatusPage>,
|
||||
#[template_child]
|
||||
pub list_view: TemplateChild<gtk::ScrolledWindow>,
|
||||
#[template_child]
|
||||
pub message_scroll: TemplateChild<gtk::ScrolledWindow>,
|
||||
#[template_child]
|
||||
pub banner: TemplateChild<adw::Banner>,
|
||||
pub notifier: OnceCell<system_notifier::Client>,
|
||||
pub conn: OnceCell<gio::SocketConnection>,
|
||||
pub settings: gio::Settings,
|
||||
pub banner_binding: Cell<Option<(Subscription, glib::SignalHandlerId)>>,
|
||||
}
|
||||
|
||||
impl Default for NotifyWindow {
|
||||
fn default() -> Self {
|
||||
let this = Self {
|
||||
headerbar: TemplateChild::default(),
|
||||
message_list: TemplateChild::default(),
|
||||
entry: TemplateChild::default(),
|
||||
subscription_view: TemplateChild::default(),
|
||||
navigation_split_view: TemplateChild::default(),
|
||||
subscription_menu_btn: TemplateChild::default(),
|
||||
subscription_list: TemplateChild::default(),
|
||||
toast_overlay: TemplateChild::default(),
|
||||
stack: TemplateChild::default(),
|
||||
welcome_view: TemplateChild::default(),
|
||||
list_view: TemplateChild::default(),
|
||||
message_scroll: TemplateChild::default(),
|
||||
banner: TemplateChild::default(),
|
||||
subscription_list_model: gio::ListStore::new::<Subscription>(),
|
||||
settings: gio::Settings::new(APP_ID),
|
||||
notifier: OnceCell::new(),
|
||||
conn: OnceCell::new(),
|
||||
banner_binding: Cell::new(None),
|
||||
};
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
#[gtk::template_callbacks]
|
||||
impl NotifyWindow {
|
||||
#[template_callback]
|
||||
fn show_add_topic(&self, _btn: >k::Button) {
|
||||
let dialog = AddSubscriptionDialog::new();
|
||||
dialog.set_transient_for(Some(&self.obj().clone()));
|
||||
dialog.present();
|
||||
|
||||
let this = self.obj().clone();
|
||||
let dc = dialog.clone();
|
||||
dialog.connect_local("subscribe-request", true, move |_| {
|
||||
this.add_subscription(&dc.server(), &dc.topic());
|
||||
None
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for NotifyWindow {
|
||||
const NAME: &'static str = "NotifyWindow";
|
||||
type Type = super::NotifyWindow;
|
||||
type ParentType = adw::ApplicationWindow;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
klass.bind_template();
|
||||
klass.bind_template_callbacks();
|
||||
|
||||
klass.install_action("win.unsubscribe", None, |this, _, _| {
|
||||
this.unsubscribe();
|
||||
});
|
||||
klass.install_action("win.show-subscription-info", None, |this, _, _| {
|
||||
this.show_subscription_info();
|
||||
});
|
||||
klass.install_action("win.clear-notifications", None, |this, _, _| {
|
||||
/*spawn_local(this.subscription().clear_notifications().map_err(this.near_toast_overlay().error_handler()));*/
|
||||
this.selected_subscription().map(|sub| {
|
||||
this.spawn_with_near_toast(sub.clear_notifications());
|
||||
});
|
||||
});
|
||||
//klass.bind_template_instance_callbacks();
|
||||
}
|
||||
|
||||
// You must call `Widget`'s `init_template()` within `instance_init()`.
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for NotifyWindow {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
let obj = self.obj();
|
||||
|
||||
// Devel Profile
|
||||
if PROFILE == "Devel" {
|
||||
obj.add_css_class("devel");
|
||||
}
|
||||
}
|
||||
|
||||
fn dispose(&self) {
|
||||
self.dispose_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for NotifyWindow {}
|
||||
impl WindowImpl for NotifyWindow {
|
||||
// Save window state on delete event
|
||||
fn close_request(&self) -> glib::Propagation {
|
||||
if let Err(err) = self.obj().save_window_size() {
|
||||
warn!(error = %err, "Failed to save window state");
|
||||
}
|
||||
|
||||
// Pass close request on to the parent
|
||||
self.parent_close_request()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationWindowImpl for NotifyWindow {}
|
||||
impl AdwApplicationWindowImpl for NotifyWindow {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct NotifyWindow(ObjectSubclass<imp::NotifyWindow>)
|
||||
@extends gtk::Widget, gtk::Window, adw::Window, adw::ApplicationWindow,
|
||||
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
|
||||
}
|
||||
|
||||
impl NotifyWindow {
|
||||
pub fn new(app: &NotifyApplication, notifier: system_notifier::Client) -> Self {
|
||||
let obj: Self = glib::Object::builder().property("application", app).build();
|
||||
|
||||
if let Err(_) = obj.imp().notifier.set(notifier) {
|
||||
panic!("setting notifier for first time");
|
||||
};
|
||||
|
||||
// Load latest window state
|
||||
obj.load_window_size();
|
||||
obj.bind_message_list();
|
||||
obj.connect_entry_changed();
|
||||
obj.connect_items_changed();
|
||||
obj.selected_subscription_changed(None);
|
||||
obj.bind_flag_read();
|
||||
|
||||
obj
|
||||
}
|
||||
fn connect_entry_changed(&self) {
|
||||
let imp = self.imp();
|
||||
let this = self.clone();
|
||||
imp.entry.connect_activate(move |entry| {
|
||||
let p = this
|
||||
.selected_subscription()
|
||||
.unwrap()
|
||||
.publish(entry.text().as_str());
|
||||
entry.spawn_with_near_toast(async move { p.await });
|
||||
});
|
||||
}
|
||||
fn show_subscription_info(&self) {
|
||||
let sub = SubscriptionInfoDialog::new(self.selected_subscription().unwrap());
|
||||
sub.set_transient_for(Some(self));
|
||||
sub.present();
|
||||
}
|
||||
fn connect_items_changed(&self) {
|
||||
let this = self.clone();
|
||||
self.imp()
|
||||
.subscription_list_model
|
||||
.connect_items_changed(move |list, _, _, _| {
|
||||
let imp = this.imp();
|
||||
if list.n_items() == 0 {
|
||||
imp.stack.set_visible_child(&*imp.welcome_view);
|
||||
} else {
|
||||
imp.stack.set_visible_child(&*imp.list_view);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn add_subscription(&self, server: &str, topic: &str) {
|
||||
let mut req = self.notifier().subscribe_request();
|
||||
|
||||
req.get().set_server(server);
|
||||
req.get().set_topic(topic);
|
||||
let res = req.send();
|
||||
let this = self.clone();
|
||||
self.spawn_with_near_toast(async move {
|
||||
let imp = this.imp();
|
||||
|
||||
// Subscription::new will use the pipelined client to retrieve info about the subscription
|
||||
let subscription = Subscription::new(res.pipeline.get_subscription());
|
||||
// We want to still check if there were any errors adding the subscription.
|
||||
res.promise.await?;
|
||||
|
||||
imp.subscription_list_model.append(&subscription);
|
||||
let i = imp.subscription_list_model.n_items() - 1;
|
||||
let row = imp.subscription_list.row_at_index(i as i32);
|
||||
imp.subscription_list.select_row(row.as_ref());
|
||||
Ok::<(), capnp::Error>(())
|
||||
});
|
||||
}
|
||||
|
||||
fn unsubscribe(&self) {
|
||||
let mut req = self.notifier().unsubscribe_request();
|
||||
|
||||
let sub = self.selected_subscription().unwrap();
|
||||
|
||||
req.get().set_server(&sub.server());
|
||||
req.get().set_topic(&sub.topic());
|
||||
|
||||
let res = req.send();
|
||||
let this = self.clone();
|
||||
|
||||
self.spawn_with_near_toast(async move {
|
||||
let imp = this.imp();
|
||||
res.promise.await?;
|
||||
|
||||
if let Some(i) = imp.subscription_list_model.find(&sub) {
|
||||
imp.subscription_list_model.remove(i);
|
||||
}
|
||||
Ok::<(), capnp::Error>(())
|
||||
});
|
||||
}
|
||||
fn notifier(&self) -> &system_notifier::Client {
|
||||
self.imp().notifier.get().unwrap()
|
||||
}
|
||||
fn selected_subscription(&self) -> Option<Subscription> {
|
||||
let imp = self.imp();
|
||||
imp.subscription_list
|
||||
.selected_row()
|
||||
.and_then(|row| imp.subscription_list_model.item(row.index() as u32))
|
||||
.and_downcast::<Subscription>()
|
||||
}
|
||||
fn bind_message_list(&self) {
|
||||
let imp = self.imp();
|
||||
|
||||
imp.subscription_list
|
||||
.bind_model(Some(&imp.subscription_list_model), |obj| {
|
||||
let sub = obj.downcast_ref::<Subscription>().unwrap();
|
||||
|
||||
Self::build_subscription_ui(&sub).upcast()
|
||||
});
|
||||
|
||||
let this = self.clone();
|
||||
imp.subscription_list.connect_row_selected(move |_, _row| {
|
||||
this.selected_subscription_changed(this.selected_subscription().as_ref());
|
||||
});
|
||||
|
||||
let this = self.clone();
|
||||
let req = self.notifier().list_subscriptions_request();
|
||||
let res = req.send();
|
||||
self.spawn_with_near_toast(async move {
|
||||
let list = res.promise.await?;
|
||||
let list = list.get()?.get_list()?;
|
||||
let imp = this.imp();
|
||||
for sub in list {
|
||||
imp.subscription_list_model.append(&Subscription::new(sub?));
|
||||
}
|
||||
Ok::<(), capnp::Error>(())
|
||||
});
|
||||
}
|
||||
fn update_banner(&self, sub: Option<&Subscription>) {
|
||||
let imp = self.imp();
|
||||
if let Some(sub) = sub {
|
||||
match sub.nice_status() {
|
||||
Status::Degraded | Status::Down => imp.banner.set_revealed(true),
|
||||
Status::Up => imp.banner.set_revealed(false),
|
||||
}
|
||||
} else {
|
||||
imp.banner.set_revealed(false);
|
||||
}
|
||||
}
|
||||
fn selected_subscription_changed(&self, sub: Option<&Subscription>) {
|
||||
let imp = self.imp();
|
||||
self.update_banner(sub);
|
||||
if let Some((sub, id)) = imp.banner_binding.take() {
|
||||
sub.disconnect(id);
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
imp.navigation_split_view.set_show_content(true);
|
||||
imp.message_list
|
||||
.bind_model(Some(&sub.imp().messages), move |obj| {
|
||||
let b = obj.downcast_ref::<glib::BoxedAnyObject>().unwrap();
|
||||
let msg = b.borrow::<models::Message>();
|
||||
|
||||
MessageRow::new(msg.clone()).upcast()
|
||||
});
|
||||
imp.subscription_menu_btn.set_visible(true);
|
||||
imp.entry.set_sensitive(true);
|
||||
|
||||
let this = self.clone();
|
||||
imp.banner_binding.set(Some((
|
||||
sub.clone(),
|
||||
sub.connect_status_notify(move |sub| {
|
||||
this.update_banner(Some(sub));
|
||||
}),
|
||||
)));
|
||||
|
||||
let this = self.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
this.flag_read();
|
||||
});
|
||||
} else {
|
||||
imp.message_list
|
||||
.bind_model(gio::ListModel::NONE, |_| adw::Bin::new().into());
|
||||
imp.subscription_menu_btn.set_visible(false);
|
||||
imp.entry.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
fn flag_read(&self) {
|
||||
let vadj = self.imp().message_scroll.vadjustment();
|
||||
// There is nothing to scroll, so the user viewed all the messages
|
||||
if vadj.page_size() == vadj.upper()
|
||||
|| ((vadj.page_size() + vadj.value() - vadj.upper()).abs() <= 1.0)
|
||||
{
|
||||
self.selected_subscription().map(|sub| {
|
||||
self.spawn_with_near_toast(sub.flag_all_as_read());
|
||||
});
|
||||
}
|
||||
}
|
||||
fn build_chip(text: &str) -> gtk::Label {
|
||||
let chip = gtk::Label::new(Some(text));
|
||||
chip.add_css_class("chip");
|
||||
chip.add_css_class("chip--small");
|
||||
chip.set_margin_top(4);
|
||||
chip.set_margin_bottom(4);
|
||||
chip.set_margin_start(4);
|
||||
chip.set_margin_end(4);
|
||||
chip.set_halign(gtk::Align::Center);
|
||||
chip.set_valign(gtk::Align::Center);
|
||||
chip
|
||||
}
|
||||
|
||||
fn build_subscription_ui(sub: &Subscription) -> impl glib::IsA<gtk::Widget> {
|
||||
let b = gtk::Box::builder().spacing(8).build();
|
||||
|
||||
let label = gtk::Label::builder()
|
||||
.xalign(0.0)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.wrap(true)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
sub.bind_property("display-name", &label, "label")
|
||||
.sync_create()
|
||||
.build();
|
||||
|
||||
let counter_chip = Self::build_chip("1+");
|
||||
counter_chip.add_css_class("chip--info");
|
||||
counter_chip.set_visible(false);
|
||||
let counter_chip_clone = counter_chip.clone();
|
||||
sub.connect_unread_count_notify(move |sub| {
|
||||
let c = sub.unread_count();
|
||||
counter_chip_clone.set_visible(c > 0);
|
||||
});
|
||||
|
||||
let status_chip = Self::build_chip("Degraded");
|
||||
let status_chip_clone = status_chip.clone();
|
||||
|
||||
sub.connect_status_notify(move |sub| match sub.nice_status() {
|
||||
Status::Degraded | Status::Down => {
|
||||
status_chip_clone.add_css_class("chip--degraded");
|
||||
status_chip_clone.set_visible(true);
|
||||
}
|
||||
_ => {
|
||||
status_chip_clone.set_visible(false);
|
||||
}
|
||||
});
|
||||
|
||||
b.append(&counter_chip);
|
||||
b.append(&label);
|
||||
b.append(&status_chip);
|
||||
|
||||
b
|
||||
}
|
||||
|
||||
fn save_window_size(&self) -> Result<(), glib::BoolError> {
|
||||
let imp = self.imp();
|
||||
|
||||
let (width, height) = self.default_size();
|
||||
|
||||
imp.settings.set_int("window-width", width)?;
|
||||
imp.settings.set_int("window-height", height)?;
|
||||
|
||||
imp.settings
|
||||
.set_boolean("is-maximized", self.is_maximized())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn bind_flag_read(&self) {
|
||||
let imp = self.imp();
|
||||
|
||||
let this = self.clone();
|
||||
imp.message_scroll.connect_edge_reached(move |_, pos_type| {
|
||||
if pos_type == gtk::PositionType::Bottom {
|
||||
this.flag_read();
|
||||
}
|
||||
});
|
||||
let this = self.clone();
|
||||
self.connect_is_active_notify(move |_| {
|
||||
if this.is_active() {
|
||||
this.flag_read();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn load_window_size(&self) {
|
||||
let imp = self.imp();
|
||||
|
||||
let width = imp.settings.int("window-width");
|
||||
let height = imp.settings.int("window-height");
|
||||
let is_maximized = imp.settings.boolean("is-maximized");
|
||||
|
||||
self.set_default_size(width, height);
|
||||
|
||||
if is_maximized {
|
||||
self.maximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/window.rs
118
src/window.rs
@ -1,118 +0,0 @@
|
||||
use adw::subclass::prelude::*;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{gio, glib};
|
||||
|
||||
use crate::application::ExampleApplication;
|
||||
use crate::config::{APP_ID, PROFILE};
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, gtk::CompositeTemplate)]
|
||||
#[template(resource = "/com/ranfdev/Notify/ui/window.ui")]
|
||||
pub struct ExampleApplicationWindow {
|
||||
#[template_child]
|
||||
pub headerbar: TemplateChild<adw::HeaderBar>,
|
||||
pub settings: gio::Settings,
|
||||
}
|
||||
|
||||
impl Default for ExampleApplicationWindow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
headerbar: TemplateChild::default(),
|
||||
settings: gio::Settings::new(APP_ID),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for ExampleApplicationWindow {
|
||||
const NAME: &'static str = "ExampleApplicationWindow";
|
||||
type Type = super::ExampleApplicationWindow;
|
||||
type ParentType = adw::ApplicationWindow;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
klass.bind_template();
|
||||
}
|
||||
|
||||
// You must call `Widget`'s `init_template()` within `instance_init()`.
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for ExampleApplicationWindow {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
let obj = self.obj();
|
||||
|
||||
// Devel Profile
|
||||
if PROFILE == "Devel" {
|
||||
obj.add_css_class("devel");
|
||||
}
|
||||
|
||||
// Load latest window state
|
||||
obj.load_window_size();
|
||||
}
|
||||
|
||||
fn dispose(&self) {
|
||||
self.dispose_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for ExampleApplicationWindow {}
|
||||
impl WindowImpl for ExampleApplicationWindow {
|
||||
// Save window state on delete event
|
||||
fn close_request(&self) -> gtk::Inhibit {
|
||||
if let Err(err) = self.obj().save_window_size() {
|
||||
tracing::warn!("Failed to save window state, {}", &err);
|
||||
}
|
||||
|
||||
// Pass close request on to the parent
|
||||
self.parent_close_request()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationWindowImpl for ExampleApplicationWindow {}
|
||||
impl AdwApplicationWindowImpl for ExampleApplicationWindow {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct ExampleApplicationWindow(ObjectSubclass<imp::ExampleApplicationWindow>)
|
||||
@extends gtk::Widget, gtk::Window, adw::Window, adw::ApplicationWindow,
|
||||
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
|
||||
}
|
||||
|
||||
impl ExampleApplicationWindow {
|
||||
pub fn new(app: &ExampleApplication) -> Self {
|
||||
glib::Object::builder().property("application", app).build()
|
||||
}
|
||||
|
||||
fn save_window_size(&self) -> Result<(), glib::BoolError> {
|
||||
let imp = self.imp();
|
||||
|
||||
let (width, height) = self.default_size();
|
||||
|
||||
imp.settings.set_int("window-width", width)?;
|
||||
imp.settings.set_int("window-height", height)?;
|
||||
|
||||
imp.settings
|
||||
.set_boolean("is-maximized", self.is_maximized())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_window_size(&self) {
|
||||
let imp = self.imp();
|
||||
|
||||
let width = imp.settings.int("window-width");
|
||||
let height = imp.settings.int("window-height");
|
||||
let is_maximized = imp.settings.boolean("is-maximized");
|
||||
|
||||
self.set_default_size(width, height);
|
||||
|
||||
if is_maximized {
|
||||
self.maximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user