Compare commits

..

3 Commits

Author SHA1 Message Date
60c22a282e Load avatars from xmpp 2024-06-04 19:20:54 +02:00
c9323b38de Add logging 2024-06-04 19:19:35 +02:00
d5af178c91 Ignore data folder in git 2024-06-04 19:15:44 +02:00
13 changed files with 122 additions and 464 deletions

View File

@ -14,9 +14,7 @@ html-escape = "0.2.13"
hsluv = "0.3.1"
sha1 = "0.10.6"
tokio = { version = "1", features = [ "rt" ] }
xmpp = { git = "https://gitlab.com/xmpp-rs/xmpp-rs", features = [ "syntax-highlighting", "serde" ] }
xmpp = { git = "https://gitlab.com/xmpp-rs/xmpp-rs" }
async-channel = "2.3.1"
env_logger = { version = "0.11.3", default-features = false, features = ["color", "auto-color", "humantime"] }
camino = "1.1"
serde = { version = "1.0", features = [ "derive" ] }
toml = "0.8"
pretty_env_logger = "0.5"
log = "0.4"

View File

@ -1,83 +0,0 @@
use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use xmpp::BareJid;
use crate::xdg::xdg_config;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ConfigFile {
pub accounts: Vec<AccountConfig>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AccountConfig {
jid: BareJid,
password: String,
}
impl AccountConfig {
pub fn new(jid: &BareJid, password: &str) -> Self {
Self {
jid: jid.clone(),
password: password.to_string(),
}
}
}
impl ConfigFile {
pub fn xdg_path() -> Utf8PathBuf {
xdg_config("rino").join("config.toml")
}
pub fn from_xdg() -> Self {
let config_path = Self::xdg_path();
if config_path.is_file() {
let content = std::fs::read_to_string(&config_path).unwrap();
toml::from_str(&content).unwrap()
} else {
Self::default()
}
}
pub fn to_xdg(&self) {
let config_path = Self::xdg_path();
let config_dir = config_path.parent().unwrap();
if !config_dir.is_dir() {
std::fs::create_dir_all(&config_dir).unwrap();
}
std::fs::write(&config_path, toml::to_string(&self).unwrap()).unwrap()
}
pub fn get_account_password(&self, jid: &BareJid) -> Option<&str> {
self.accounts
.iter()
.find(|acc| &acc.jid == jid)
.map(|x| x.password.as_str())
}
pub fn save_account(&mut self, jid: &BareJid, password: &str) {
if let Some(p) = self.get_account_password(jid) {
if p == password {
return;
}
for acc in self.accounts.iter_mut() {
if &acc.jid == jid {
acc.password = password.to_string();
}
}
} else {
let account = AccountConfig::new(jid, password);
self.accounts.push(account);
}
self.to_xdg();
}
pub fn get_first_account(&self) -> Option<(&BareJid, &String)> {
for account in &self.accounts {
return Some((&account.jid, &account.password));
}
return None;
}
}

View File

@ -1,35 +0,0 @@
use gtk::gio::ListStore;
use xmpp::BareJid;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use crate::poezio_logs::load_logs;
use crate::Message;
pub type InnerMessageStore = HashMap<BareJid, ListStore>;
#[derive(Clone, Debug, Default)]
pub struct MessageStore {
/// complete message history
store: Arc<RwLock<InnerMessageStore>>,
}
impl<'a> MessageStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_jid(&self, jid: &BareJid) -> ListStore {
self.store
.write()
.unwrap()
.entry(jid.clone())
.or_insert(load_logs(jid.as_str()))
.clone()
}
pub fn insert_message(&self, jid: &BareJid, message: &Message) {
self.with_jid(jid).append(message);
}
}

View File

@ -1 +0,0 @@
pub mod message_store;

View File

@ -18,20 +18,13 @@ use adw::prelude::*;
use adw::subclass::prelude::ObjectSubclassIsExt;
use gtk::{gio, glib};
use std::sync::{Arc, RwLock};
mod config;
mod helpers;
mod message;
mod poezio_logs;
mod tab;
mod widgets;
mod window;
mod xdg;
mod xmpp_client;
use config::ConfigFile;
use helpers::message_store::MessageStore;
use message::Message;
use tab::Tab;
@ -48,33 +41,28 @@ fn xep_0392(input: &str) -> String {
format!("#{:02x}{:02x}{:02x}", r, g, b)
}
fn on_login_pressed(
win: window::MainWindow,
config: Arc<RwLock<ConfigFile>>,
messages: MessageStore,
xmpp_cmd: xmpp_client::CommandSender,
) {
let jid = win.jid().text();
let password = win.password().text();
fn main() {
pretty_env_logger::init();
if !jid.is_empty() && !password.is_empty() {
win.stack().set_visible_child(win.spinner());
let mut args = std::env::args();
let _ = args.next().unwrap();
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);
xmpp_cmd.set_sender(cmd_sender);
let app = adw::Application::builder()
.application_id("fr.linkmauve.XmppClient")
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
let tabs_store = gio::ListStore::new::<Tab>();
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::Online => {
// Success login, save password
config
.write()
.unwrap()
.save_account(&xmpp::BareJid::new(jid.as_str()).unwrap(), &password);
win.stack().set_visible_child(win.split_view());
}
xmpp_client::XMPPEvent::Avatar(jid, avatar) => {
if let Some(tab) = win.tabs_store().iter().find(|tab| {
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()
@ -84,54 +72,21 @@ fn on_login_pressed(
}
}
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);
}
win.tabs_store().append(&tab);
}
xmpp_client::XMPPEvent::PM(jid, body, time) => {
let message = Message::new(&time, jid.as_str(), &body);
messages.insert_message(&jid, &message);
tabs_store_copy.append(&tab);
}
}
}
});
}
}
fn main() {
env_logger::init();
let config = Arc::new(RwLock::new(ConfigFile::from_xdg()));
let messages = MessageStore::new();
let xmpp_cmd = xmpp_client::CommandSender::new();
let app = adw::Application::builder()
.application_id("fr.linkmauve.XmppClient")
.flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
app.connect_command_line(move |app, command_line| {
let args = command_line.arguments();
let mut iter = args.iter();
iter.next().unwrap();
let win = app.active_window().unwrap();
let win: &window::MainWindow = win.downcast_ref().unwrap();
for arg in iter {
let arg = arg.to_str().unwrap().to_owned();
if let Some(jid) = arg.strip_prefix("xmpp:") {
let tab = Tab::new(jid, jid);
win.tabs_store().append(&tab);
}
}
0
});
app.connect_startup(move |app| {
let win = window::MainWindow::new(app, config.clone());
let win = window::MainWindow::new(app);
let action_close = gio::ActionEntry::builder("close")
.activate(|window: &window::MainWindow, _, _| {
@ -141,19 +96,6 @@ fn main() {
win.add_action_entries([action_close]);
app.set_accels_for_action("win.close", &["<Ctrl>Q"]);
let win2 = win.clone();
let config2 = config.clone();
let messages2 = messages.clone();
let xmpp_cmd2 = xmpp_cmd.clone();
win.login().connect_clicked(move |_| {
on_login_pressed(
win2.clone(),
config2.clone(),
messages2.clone(),
xmpp_cmd2.clone(),
);
});
assert!(Tab::static_type().is_valid());
assert!(Message::static_type().is_valid());
@ -215,16 +157,15 @@ fn main() {
});
win.tabs().set_factory(Some(&tabs_factory));
win.tabs_selection().set_model(Some(win.tabs_store()));
win.tabs_selection().set_model(Some(&tabs_store));
let win2 = win.clone();
let messages2 = messages.clone();
win.tabs_selection()
.connect_selection_changed(move |tabs_selection, _, _| {
let item = tabs_selection.selected_item().unwrap();
let tab: &Tab = item.downcast_ref().unwrap();
println!("Switching to {}", tab.jid());
let store = messages2.with_jid(&xmpp::BareJid::new(&tab.jid()).unwrap());
let store = poezio_logs::load_logs(&tab.jid());
let selection = win2.selection();
selection.set_model(Some(&store));
win2.messages().scroll_to(
@ -237,7 +178,7 @@ fn main() {
});
let win2 = win.clone();
let xmpp_cmd2 = xmpp_cmd.clone();
let cmd_sender2 = cmd_sender.clone();
win.entry().connect_activate(move |entry| {
let text = entry.text();
entry.set_text("");
@ -247,10 +188,12 @@ fn main() {
.selected_item()
.and_downcast()
.unwrap();
xmpp_cmd2.send(xmpp_client::XMPPCommand::SendPM(
cmd_sender2
.send_blocking(xmpp_client::XMPPCommand::SendPM(
xmpp::BareJid::new(&current_tab.jid()).unwrap(),
text.as_str().to_string(),
));
))
.unwrap();
let message = Message::now(&get_own_nick(), &text);
let selection = win2.selection();
let store = selection

View File

@ -25,9 +25,9 @@ use nom::{
};
use std::fs::read_to_string;
use std::path::PathBuf;
use std::str::FromStr;
use crate::xdg::xdg_data;
use crate::Message;
pub trait LogItem {
@ -155,7 +155,13 @@ pub enum Item<'a> {
}
pub fn load_logs(jid: &str) -> gio::ListStore {
let entry = xdg_data("poezio").join("logs").join(jid);
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or(format!(
"{}/.local/share",
std::env::var("HOME").expect("NO $HOME?!")
));
let xdg_data = PathBuf::from(xdg_data);
let entry = xdg_data.join("poezio/logs").join(jid);
let logs = read_to_string(entry).unwrap_or("".to_string());
let (_, logs) = parse_logs(&logs).unwrap();
let store = gio::ListStore::new::<Message>();

View File

@ -71,8 +71,15 @@ impl ChatTab {
self.set_tooltip_text(Some(jid));
}
// TODO: currently file path, not a hash
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));
}

View File

@ -13,7 +13,6 @@
</child>
<child>
<object class="GtkLabel" id="name">
<property name="ellipsize">end</property>
<property name="halign">start</property>
<property name="margin-top">4</property>
<property name="margin-bottom">4</property>

View File

@ -15,30 +15,12 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use adw::subclass::prelude::*;
use gtk::{gio, glib};
use gtk::glib;
/// The private struct, which can hold widgets and other data.
#[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(file = "window.ui")]
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]
pub split_view: TemplateChild<adw::NavigationSplitView>,
#[template_child]

View File

@ -16,14 +16,9 @@
mod imp;
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::{gio, glib};
use std::sync::{Arc, RwLock};
use crate::config::ConfigFile;
glib::wrapper! {
pub struct MainWindow(ObjectSubclass<imp::MainWindow>)
@extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow,
@ -31,37 +26,8 @@ glib::wrapper! {
}
impl MainWindow {
pub fn new(app: &adw::Application, config: Arc<RwLock<ConfigFile>>) -> Self {
let win: Self = glib::Object::builder().property("application", app).build();
if let Some((jid, password)) = config.read().unwrap().get_first_account() {
win.jid().set_text(jid.as_str());
win.password().set_text(password);
}
win
}
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 new(app: &adw::Application) -> Self {
glib::Object::builder().property("application", app).build()
}
pub fn split_view(&self) -> &adw::NavigationSplitView {

View File

@ -13,68 +13,6 @@
</object>
</child>
<property name="content">
<object class="GtkStack" id="stack">
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar"/>
</child>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Enter your XMPP credentials</property>
<child>
<object class="AdwEntryRow" id="jid">
<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>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="login">
<property name="label" translatable="yes">Login</property>
<property name="css-classes">suggested-action</property>
<property name="margin-top">8</property>
</object>
</child>
</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">
@ -130,7 +68,6 @@
<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>
<!--
@ -149,11 +86,8 @@
</object>
</property>
</object>
</child>
</object>
</property>
</template>
<object class="GListStore" id="tabs_store"/>
<object class="GtkSingleSelection" id="tabs_selection"/>
<object class="GtkNoSelection" id="selection"/>
</interface>

View File

@ -1,26 +0,0 @@
use camino::Utf8PathBuf;
pub fn xdg_data(app: &str) -> Utf8PathBuf {
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or(format!(
"{}/.local/share",
std::env::var("HOME").expect("NO $HOME?!")
));
Utf8PathBuf::from(xdg_data).join(app)
}
#[allow(dead_code)]
pub fn xdg_cache(app: &str) -> Utf8PathBuf {
let xdg_cache = std::env::var("XDG_CACHE_HOME").unwrap_or(format!(
"{}/.cache",
std::env::var("HOME").expect("NO $HOME?!")
));
Utf8PathBuf::from(xdg_cache).join(app)
}
pub fn xdg_config(app: &str) -> Utf8PathBuf {
let xdg_config = std::env::var("XDG_CONFIG_HOME").unwrap_or(format!(
"{}/.config",
std::env::var("HOME").expect("NO $HOME?!")
));
Utf8PathBuf::from(xdg_config).join(app)
}

View File

@ -1,45 +1,19 @@
use async_channel::{Receiver, Sender};
use chrono::{DateTime, Utc};
use tokio::runtime::Runtime;
use xmpp::{BareJid, ClientBuilder, ClientFeature};
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::sync::OnceLock;
#[derive(Clone, Debug)]
pub enum XMPPCommand {
SendPM(BareJid, String),
}
#[derive(Clone, Debug)]
pub struct CommandSender(Arc<RwLock<Option<Sender<XMPPCommand>>>>);
impl CommandSender {
pub fn new() -> Self {
Self(Arc::new(RwLock::new(None)))
}
pub fn set_sender(&self, sender: Sender<XMPPCommand>) {
*self.0.write().unwrap() = Some(sender);
}
pub fn send(&self, command: XMPPCommand) {
self.0
.write()
.unwrap()
.as_ref()
.expect("bug: CommandSender not init")
.send_blocking(command)
.unwrap();
}
}
#[derive(Clone, Debug)]
pub enum XMPPEvent {
Online,
Avatar(BareJid, String),
Contact(BareJid, Option<String>),
PM(BareJid, String, DateTime<Utc>),
}
pub fn tokio_runtime() -> &'static Runtime {
@ -49,7 +23,6 @@ pub fn tokio_runtime() -> &'static Runtime {
pub async fn handle_xmpp_event(event: xmpp::Event) -> Option<XMPPEvent> {
match event {
xmpp::Event::Online => Some(XMPPEvent::Online),
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
@ -68,6 +41,7 @@ pub async fn handle_xmpp_event(event: xmpp::Event) -> Option<XMPPEvent> {
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;
}
}
@ -90,22 +64,16 @@ pub async fn handle_xmpp_event(event: xmpp::Event) -> Option<XMPPEvent> {
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) => {
if from.is_bare() {
Some(XMPPEvent::PM(
from.to_bare(),
body.0.to_string(),
time.received.clone(),
))
} else {
xmpp::Event::ChatMessage(_id, _from, _body, _time) => {
// TODO: Insert message into tab history
None
}
}
_ => None,
}
}
@ -134,8 +102,8 @@ pub(crate) fn client(jid: &str, password: &str) -> (Receiver<XMPPEvent>, Sender<
}
}
Ok(command) = cmd_receiver.recv() => {
match command {
command = cmd_receiver.recv() => {
match command.unwrap() {
XMPPCommand::SendPM(jid, content) => {
client.send_message(jid.into(), xmpp::parsers::message::MessageType::Chat, "en", &content).await;
},