Compare commits

..

4 Commits

Author SHA1 Message Date
Emmanuel Gil Peyrot 5824275f06 Implement a login page
That way we connect only after the UI is created.
2024-06-04 18:10:53 +02:00
Emmanuel Gil Peyrot 690e7113ab Fix warning caused by removing command-line handling
This will be readded as soon as we support xmpp: URIs.
2024-06-04 18:07:17 +02:00
Emmanuel Gil Peyrot f09a1cff2d Enable syntax-highlighting in tokio-xmpp
This makes logs much nicer to read.
2024-06-04 18:06:22 +02:00
Emmanuel Gil Peyrot 7c85e6e006 Enable env_logger
This is probably temporary, as we will most likely want to have a debug
console, but this will do for now.
2024-06-04 18:01:36 +02:00
9 changed files with 237 additions and 199 deletions
-1
View File
@@ -1,3 +1,2 @@
/target /target
Cargo.lock Cargo.lock
/data
+2 -2
View File
@@ -15,6 +15,6 @@ hsluv = "0.3.1"
sha1 = "0.10.6" sha1 = "0.10.6"
tokio = { version = "1", features = [ "rt" ] } tokio = { version = "1", features = [ "rt" ] }
xmpp = { git = "https://gitlab.com/xmpp-rs/xmpp-rs" } xmpp = { git = "https://gitlab.com/xmpp-rs/xmpp-rs" }
tokio-xmpp = { git = "https://gitlab.com/xmpp-rs/xmpp-rs", features = ["syntax-highlighting"] }
async-channel = "2.3.1" async-channel = "2.3.1"
pretty_env_logger = "0.5" env_logger = { version = "0.11.3", default-features = false, features = ["color", "auto-color", "humantime"] }
log = "0.4"
+42 -51
View File
@@ -15,7 +15,6 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use adw::prelude::*; use adw::prelude::*;
use adw::subclass::prelude::ObjectSubclassIsExt;
use gtk::{gio, glib}; use gtk::{gio, glib};
mod message; mod message;
@@ -41,51 +40,45 @@ fn xep_0392(input: &str) -> String {
format!("#{:02x}{:02x}{:02x}", r, g, b) format!("#{:02x}{:02x}{:02x}", r, g, b)
} }
fn main() { fn on_login_pressed(win: &window::MainWindow) {
pretty_env_logger::init(); let jid = win.jid().text();
let password = win.password().text();
println!("jid={jid:?} password={password:?}");
let mut args = std::env::args(); if !jid.is_empty() && !password.is_empty() {
let _ = args.next().unwrap(); win.stack().set_visible_child(win.spinner());
let username = args.next().expect("Please give username argument 1");
let password = args.next().expect("Please give password argument 2"); let (xmpp_receiver, cmd_sender) = xmpp_client::client(&jid, &password);
let win2 = win.clone();
glib::spawn_future_local(async move {
while let Ok(event) = xmpp_receiver.recv().await {
match event {
xmpp::Event::Online => {
win2.stack().set_visible_child(win2.split_view());
}
xmpp::Event::ContactAdded(jid) => {
let tab = Tab::new(jid.jid.as_str(), jid.jid.as_str());
win2.tabs_store().append(&tab);
}
xmpp::Event::ChatMessage(_id, _from, _body, _time) => {
// TODO: Insert message into tab history
continue;
}
_ => continue,
}
}
});
}
}
fn main() {
env_logger::init();
let app = adw::Application::builder() let app = adw::Application::builder()
.application_id("fr.linkmauve.XmppClient") .application_id("fr.linkmauve.XmppClient")
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
.build(); .build();
let tabs_store = gio::ListStore::new::<Tab>(); app.connect_activate(move |app| {
let tabs_store_copy = tabs_store.clone();
let (xmpp_receiver, cmd_sender) = xmpp_client::client(&username, &password);
glib::spawn_future_local(async move {
while let Ok(event) = xmpp_receiver.recv().await {
match event {
xmpp_client::XMPPEvent::Avatar(jid, avatar) => {
log::info!("AVATAR");
if let Some(tab) = tabs_store_copy.iter().find(|tab| {
let tab: &glib::object::Object = tab.as_ref().unwrap();
let tab: &Tab = tab.downcast_ref().unwrap();
tab.jid() == jid.as_str()
}) {
let tab: &Tab = tab.as_ref().unwrap().downcast_ref().unwrap();
tab.set_avatar_hash(avatar.clone());
}
}
xmpp_client::XMPPEvent::Contact(jid, avatar) => {
log::info!("CONTACT");
let tab = Tab::new(jid.as_str(), jid.as_str());
if let Some(avatar) = avatar {
tab.set_avatar_hash(avatar);
}
tabs_store_copy.append(&tab);
}
}
}
});
app.connect_startup(move |app| {
let win = window::MainWindow::new(app); let win = window::MainWindow::new(app);
let action_close = gio::ActionEntry::builder("close") let action_close = gio::ActionEntry::builder("close")
@@ -96,6 +89,11 @@ fn main() {
win.add_action_entries([action_close]); win.add_action_entries([action_close]);
app.set_accels_for_action("win.close", &["<Ctrl>Q"]); app.set_accels_for_action("win.close", &["<Ctrl>Q"]);
let win2 = win.clone();
win.login().connect_clicked(move |_| {
on_login_pressed(&win2);
});
assert!(Tab::static_type().is_valid()); assert!(Tab::static_type().is_valid());
assert!(Message::static_type().is_valid()); assert!(Message::static_type().is_valid());
@@ -139,25 +137,16 @@ fn main() {
list_item.set_child(Some(&tab_widget)); list_item.set_child(Some(&tab_widget));
}); });
tabs_factory.connect_bind(move |_, list_item| { tabs_factory.connect_bind(move |_, list_item| {
// executed when switch tab, one time for previous tab and one time for new tab
// but also when create new tab
let list_item: &gtk::ListItem = list_item.downcast_ref().unwrap(); let list_item: &gtk::ListItem = list_item.downcast_ref().unwrap();
let tab: Tab = list_item.item().and_downcast().unwrap(); let tab: Tab = list_item.item().and_downcast().unwrap();
let tab_widget: widgets::ChatTab = list_item.child().and_downcast().unwrap(); let tab_widget: widgets::ChatTab = list_item.child().and_downcast().unwrap();
tab.bind_property(
"avatar_hash",
&tab_widget.imp().avatar.try_get().unwrap(),
"file",
)
.sync_create()
.build();
tab_widget.set_name(&tab.name()); tab_widget.set_name(&tab.name());
tab_widget.set_jid(&tab.jid()); tab_widget.set_jid(&tab.jid());
}); });
win.tabs().set_factory(Some(&tabs_factory)); win.tabs().set_factory(Some(&tabs_factory));
win.tabs_selection().set_model(Some(&tabs_store)); win.tabs_selection().set_model(Some(win.tabs_store()));
let win2 = win.clone(); let win2 = win.clone();
win.tabs_selection() win.tabs_selection()
@@ -178,7 +167,7 @@ fn main() {
}); });
let win2 = win.clone(); let win2 = win.clone();
let cmd_sender2 = cmd_sender.clone(); //let cmd_sender2 = cmd_sender.clone();
win.entry().connect_activate(move |entry| { win.entry().connect_activate(move |entry| {
let text = entry.text(); let text = entry.text();
entry.set_text(""); entry.set_text("");
@@ -188,12 +177,14 @@ fn main() {
.selected_item() .selected_item()
.and_downcast() .and_downcast()
.unwrap(); .unwrap();
/*
cmd_sender2 cmd_sender2
.send_blocking(xmpp_client::XMPPCommand::SendPM( .send_blocking(xmpp_client::XMPPCommand::SendPM(
xmpp::BareJid::new(&current_tab.jid()).unwrap(), xmpp::BareJid::new(&current_tab.jid()).unwrap(),
text.as_str().to_string(), text.as_str().to_string(),
)) ))
.unwrap(); .unwrap();
*/
let message = Message::now(&get_own_nick(), &text); let message = Message::now(&get_own_nick(), &text);
let selection = win2.selection(); let selection = win2.selection();
let store = selection let store = selection
+2 -3
View File
@@ -25,8 +25,8 @@ mod imp {
#[derive(glib::Properties)] #[derive(glib::Properties)]
#[properties(wrapper_type = super::Tab)] #[properties(wrapper_type = super::Tab)]
pub struct Tab { pub struct Tab {
#[property(get, set)] //#[property(get, set)]
avatar_hash: RefCell<Option<String>>, //avatar: RefCell<String>,
#[property(get, construct_only)] #[property(get, construct_only)]
jid: RefCell<String>, jid: RefCell<String>,
#[property(get, set)] #[property(get, set)]
@@ -36,7 +36,6 @@ mod imp {
impl Default for Tab { impl Default for Tab {
fn default() -> Self { fn default() -> Self {
Tab { Tab {
avatar_hash: RefCell::new(None),
jid: RefCell::new(String::new()), jid: RefCell::new(String::new()),
name: RefCell::new(String::new()), name: RefCell::new(String::new()),
} }
+4 -12
View File
@@ -68,21 +68,13 @@ impl ChatTab {
} }
pub fn set_jid(&self, jid: &str) { pub fn set_jid(&self, jid: &str) {
let hash = "123456789abcdef123456789abcdef123456789a";
self.imp().avatar.set_from_file(Some(format!(
"/home/linkmauve/cache/poezio/avatars/{jid}/{hash}"
)));
self.set_tooltip_text(Some(jid)); self.set_tooltip_text(Some(jid));
} }
pub fn set_avatar_hash(&self, hash: &str) {
// let xdg_cache = std::env::var("XDG_CACHE_HOME").unwrap_or(format!(
// "{}/.cache",
// std::env::var("HOME").expect("NO $HOME?!")
// ));
// let xdg_cache = PathBuf::from(xdg_cache);
// let entry = xdg_cache.join("poezio/avatars").join(self.tooltip_text().unwrap()).join(hash);
// self.imp().avatar.set_from_file(Some(entry.to_str().unwrap().to_string()));
self.imp().avatar.set_from_file(Some(hash));
}
pub fn set_name(&self, name: &str) { pub fn set_name(&self, name: &str) {
self.imp().name.set_label(name); self.imp().name.set_label(name);
} }
+22 -1
View File
@@ -15,12 +15,33 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use gtk::glib; use gtk::{gio, glib};
/// The private struct, which can hold widgets and other data. /// The private struct, which can hold widgets and other data.
#[derive(Debug, Default, gtk::CompositeTemplate)] #[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(file = "window.ui")] #[template(file = "window.ui")]
pub struct MainWindow { pub struct MainWindow {
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
// For the login page
#[template_child]
pub jid: TemplateChild<adw::EntryRow>,
#[template_child]
pub password: TemplateChild<adw::PasswordEntryRow>,
#[template_child]
pub login: TemplateChild<gtk::Button>,
// For the spinner
#[template_child]
pub spinner: TemplateChild<gtk::Box>,
// For the chats page
#[template_child]
pub tabs_store: TemplateChild<gio::ListStore>,
#[template_child] #[template_child]
pub split_view: TemplateChild<adw::NavigationSplitView>, pub split_view: TemplateChild<adw::NavigationSplitView>,
#[template_child] #[template_child]
+24
View File
@@ -30,6 +30,30 @@ impl MainWindow {
glib::Object::builder().property("application", app).build() glib::Object::builder().property("application", app).build()
} }
pub fn stack(&self) -> &gtk::Stack {
&self.imp().stack
}
pub fn jid(&self) -> &adw::EntryRow {
&self.imp().jid
}
pub fn password(&self) -> &adw::PasswordEntryRow {
&self.imp().password
}
pub fn login(&self) -> &gtk::Button {
&self.imp().login
}
pub fn spinner(&self) -> &gtk::Box {
&self.imp().spinner
}
pub fn tabs_store(&self) -> &gio::ListStore {
&self.imp().tabs_store
}
pub fn split_view(&self) -> &adw::NavigationSplitView { pub fn split_view(&self) -> &adw::NavigationSplitView {
&self.imp().split_view &self.imp().split_view
} }
+122 -56
View File
@@ -13,81 +13,147 @@
</object> </object>
</child> </child>
<property name="content"> <property name="content">
<object class="AdwNavigationSplitView" id="split_view"> <object class="GtkStack" id="stack">
<property name="sidebar"> <child>
<object class="AdwNavigationPage"> <object class="GtkBox">
<property name="title" translatable="yes">Open Chats</property> <property name="orientation">vertical</property>
<property name="child"> <child>
<object class="AdwToolbarView"> <object class="AdwHeaderBar"/>
<child type="top"> </child>
<object class="AdwHeaderBar"/> <child>
</child> <object class="AdwClamp">
<property name="content"> <child>
<object class="GtkScrolledWindow">
<property name="vexpand">yes</property>
<child>
<object class="GtkListView" id="tabs_list">
<property name="model">tabs_selection</property>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</property>
<property name="content">
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Messages</property>
<property name="tag">chat</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="GtkScrolledWindow"> <object class="AdwPreferencesGroup">
<property name="vexpand">yes</property> <property name="title" translatable="yes">Enter your XMPP credentials</property>
<child> <child>
<object class="GtkListView" id="message_list_view"> <object class="AdwEntryRow" id="jid">
<property name="model">selection</property> <property name="title" translatable="yes">JID</property>
<property name="input-purpose">email</property>
</object>
</child>
<child>
<object class="AdwPasswordEntryRow" id="password">
<property name="title" translatable="yes">Password</property>
<property name="input-purpose">password</property>
</object> </object>
</child> </child>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkBox"> <object class="GtkButton" id="login">
<property name="margin-start">10</property> <property name="label" translatable="yes">Login</property>
<property name="margin-top">10</property> <property name="css-classes">suggested-action</property>
<property name="margin-end">10</property> <property name="margin-top">8</property>
<property name="margin-bottom">10</property>
<child>
<object class="GtkEntry" id="entry">
<property name="hexpand">yes</property>
<property name="placeholder-text" translatable="yes">Send a message</property>
</object>
</child>
<!--
<child>
<object class="GtkButton" id="send_input">
<property name="icon-name">go-next</property>
</object>
</child>
-->
</object> </object>
</child> </child>
</object> </object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="spinner">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar"/>
</child>
<child>
<object class="GtkLabel">
<property name="label">Connecting…</property>
</object>
</child>
<child>
<object class="GtkSpinner">
<property name="spinning">yes</property>
<property name="vexpand">no</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwNavigationSplitView" id="split_view">
<property name="sidebar">
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Open Chats</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="vexpand">yes</property>
<child>
<object class="GtkListView" id="tabs_list">
<property name="model">tabs_selection</property>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</property>
<property name="content">
<object class="AdwNavigationPage">
<property name="title" translatable="yes">Messages</property>
<property name="tag">chat</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">yes</property>
<child>
<object class="GtkListView" id="message_list_view">
<property name="model">selection</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="margin-start">10</property>
<property name="margin-top">10</property>
<property name="margin-end">10</property>
<property name="margin-bottom">10</property>
<child>
<object class="GtkEntry" id="entry">
<property name="hexpand">yes</property>
<property name="placeholder-text" translatable="yes">Send a message</property>
<property name="input-purpose">free-form</property>
</object>
</child>
<!--
<child>
<object class="GtkButton" id="send_input">
<property name="icon-name">go-next</property>
</object>
</child>
-->
</object>
</child>
</object>
</property>
</object>
</property> </property>
</object> </object>
</property> </property>
</object> </object>
</property> </child>
</object> </object>
</property> </property>
</template> </template>
<object class="GListStore" id="tabs_store"/>
<object class="GtkSingleSelection" id="tabs_selection"/> <object class="GtkSingleSelection" id="tabs_selection"/>
<object class="GtkNoSelection" id="selection"/> <object class="GtkNoSelection" id="selection"/>
</interface> </interface>
+19 -73
View File
@@ -1,8 +1,7 @@
use async_channel::{Receiver, Sender}; use async_channel::{Receiver, Sender};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use xmpp::{BareJid, ClientBuilder, ClientFeature}; use xmpp::{BareJid, ClientBuilder, ClientFeature, Event};
use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -10,76 +9,13 @@ pub enum XMPPCommand {
SendPM(BareJid, String), SendPM(BareJid, String),
} }
#[derive(Clone, Debug)] fn tokio_runtime() -> &'static Runtime {
pub enum XMPPEvent {
Avatar(BareJid, String),
Contact(BareJid, Option<String>),
}
pub fn tokio_runtime() -> &'static Runtime {
static RUNTIME: OnceLock<Runtime> = OnceLock::new(); static RUNTIME: OnceLock<Runtime> = OnceLock::new();
RUNTIME.get_or_init(|| Runtime::new().expect("Setting up tokio runtime needs to succeed.")) RUNTIME.get_or_init(|| Runtime::new().expect("Setting up tokio runtime needs to succeed."))
} }
pub async fn handle_xmpp_event(event: xmpp::Event) -> Option<XMPPEvent> { pub(crate) fn client(jid: &str, password: &str) -> (Receiver<xmpp::Event>, Sender<XMPPCommand>) {
match event { let (event_sender, event_receiver) = async_channel::bounded::<xmpp::Event>(1);
xmpp::Event::AvatarRetrieved(jid, hash) => {
// need update latest symlink?
// xmpp-rs stores avatar in data/JID/HASH... we create data/JID/latest symlink points to relative HASH
let mut latest_link = PathBuf::from(&hash);
let hash_name = latest_link
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string();
latest_link.set_file_name("latest");
if let Ok(metadata) = tokio::fs::metadata(&latest_link).await {
if metadata.is_symlink() {
if let Ok(previous_latest) = tokio::fs::read_link(&latest_link).await {
if previous_latest == PathBuf::from(&hash_name) {
// We already have latest symlink
log::info!("avatar Already have latest link {jid} to hash {hash}");
return None;
}
}
}
}
// Setting new latest symlink
let _ = tokio::fs::symlink(hash_name, latest_link).await;
if jid.is_bare() {
Some(XMPPEvent::Avatar(jid.to_bare(), hash))
} else {
// no avatar for FullJid (yet)
None
}
}
xmpp::Event::ContactAdded(jid) => {
let avatar_jid = jid.jid.clone();
if let Ok(hash_name) =
tokio::fs::read_link(&format!("data/{}/latest", &avatar_jid)).await
{
let avatar = format!("data/{}/{}", &avatar_jid, hash_name.display());
log::info!("Found existing avatar for contact: {avatar_jid}");
Some(XMPPEvent::Contact(avatar_jid, Some(avatar)))
} else {
Some(XMPPEvent::Contact(avatar_jid, None))
}
}
xmpp::Event::ChatMessage(_id, _from, _body, _time) => {
// TODO: Insert message into tab history
None
}
_ => None,
}
}
pub(crate) fn client(jid: &str, password: &str) -> (Receiver<XMPPEvent>, Sender<XMPPCommand>) {
let (event_sender, event_receiver) = async_channel::bounded::<XMPPEvent>(1);
let (cmd_sender, cmd_receiver) = async_channel::bounded::<XMPPCommand>(1); let (cmd_sender, cmd_receiver) = async_channel::bounded::<XMPPCommand>(1);
let jid = jid.to_string(); let jid = jid.to_string();
@@ -88,7 +24,6 @@ pub(crate) fn client(jid: &str, password: &str) -> (Receiver<XMPPEvent>, Sender<
tokio_runtime().spawn(async move { tokio_runtime().spawn(async move {
let mut client = ClientBuilder::new(BareJid::new(&jid).unwrap(), &password) let mut client = ClientBuilder::new(BareJid::new(&jid).unwrap(), &password)
.set_default_nick("xmpp-client") .set_default_nick("xmpp-client")
.enable_feature(ClientFeature::Avatars)
.enable_feature(ClientFeature::ContactList) .enable_feature(ClientFeature::ContactList)
.build(); .build();
@@ -96,14 +31,25 @@ pub(crate) fn client(jid: &str, password: &str) -> (Receiver<XMPPEvent>, Sender<
tokio::select! { tokio::select! {
Some(events) = client.wait_for_events() => { Some(events) = client.wait_for_events() => {
for event in events { for event in events {
if let Some(parsed_event) = handle_xmpp_event(event).await { match event {
let _ = event_sender.send(parsed_event).await; Event::Online => {
event_sender.send(event).await.expect("BOOOOOOHOOOO");
}
Event::ContactAdded(_) => {
event_sender.send(event).await.expect("BOOOOOOHOOOO");
}
Event::ChatMessage(_, _, _, _) => {
event_sender.send(event).await.expect("BOOOHOOOO");
}
_ => {
continue;
}
} }
} }
} }
command = cmd_receiver.recv() => { Ok(command) = cmd_receiver.recv() => {
match command.unwrap() { match command {
XMPPCommand::SendPM(jid, content) => { XMPPCommand::SendPM(jid, content) => {
client.send_message(jid.into(), xmpp::parsers::message::MessageType::Chat, "en", &content).await; client.send_message(jid.into(), xmpp::parsers::message::MessageType::Chat, "en", &content).await;
}, },