feat: message view

This commit is contained in:
cel 🌸 2025-03-06 10:42:44 +00:00
parent d0e1226559
commit 9bfb143c32
5 changed files with 804 additions and 442 deletions

780
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
iced = { version = "0.13.1", features = ["tokio"] }
iced = { git = "https://github.com/iced-rs/iced", features = ["tokio"] }
luz = { version = "0.1.0", path = "../luz/luz" }
jid = { version = "0.1.0", path = "../luz/jid" }
tokio = "1.43.0"
@ -19,3 +19,5 @@ serde = { version = "1.0.218", features = ["derive"] }
thiserror = "2.0.11"
toml = "0.8"
dirs = "6.0.0"
chrono-humanize = "0.2.3"
chrono = "0.4.40"

View File

@ -5,6 +5,8 @@
- the default is omemo 2, but can also choose worse omemo for contacts who do not use the client
- intention to either implement mls or omemo post quantum for later
- moving of xmpp accounts, download of account data from server
- threads in different windows, threads menu, select thread separately under person in chat list
- configure if threaded messages come under the main chat or not
- proper oauth
- guilds
- voice channels

View File

@ -7,6 +7,10 @@ use std::str::FromStr;
use std::sync::Arc;
use iced::futures::{SinkExt, Stream, StreamExt};
use iced::theme::palette::{
Background, Danger, Extended, Pair, Primary, Secondary, Success, Warning,
};
use iced::theme::{Custom, Palette};
use iced::widget::button::Status;
use iced::widget::text::{Fragment, IntoFragment};
use iced::widget::{
@ -14,15 +18,17 @@ use iced::widget::{
text_input, Column, Text, Toggler,
};
use iced::Length::Fill;
use iced::{stream, Color, Element, Subscription, Task, Theme};
use iced::{color, stream, Color, Element, Subscription, Task, Theme};
use indexmap::{indexmap, IndexMap};
use jid::JID;
use keyring::Entry;
use login_modal::{Creds, LoginModal};
use luz::chat::{Chat, Message as ChatMessage};
use luz::error::CommandError;
use luz::presence::{Offline, Presence};
use luz::CommandMessage;
use luz::{roster::Contact, user::User, LuzHandle, UpdateMessage};
use message_view::MessageView;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
@ -31,6 +37,7 @@ use tracing::{error, info};
use uuid::Uuid;
mod login_modal;
mod message_view;
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
@ -55,17 +62,12 @@ pub struct Macaw {
roster: HashMap<JID, Contact>,
users: HashMap<JID, User>,
presences: HashMap<JID, Presence>,
chats: IndexMap<JID, (Chat, IndexMap<Uuid, ChatMessage>)>,
chats: IndexMap<JID, (Chat, ChatMessage)>,
subscription_requests: HashSet<JID>,
open_chat: Option<OpenChat>,
open_chat: Option<MessageView>,
new_chat: Option<NewChat>,
}
pub struct OpenChat {
jid: JID,
new_message: String,
}
pub struct NewChat;
impl Macaw {
@ -157,7 +159,7 @@ async fn luz(jid: &JID, creds: &Creds, cfg: &Config) -> (LuzHandle, mpsc::Receiv
async fn main() -> iced::Result {
tracing_subscriber::fmt::init();
let cfg: Config = confy::load("macaw", None).unwrap();
let cfg: Config = confy::load("macaw", None).unwrap_or_default();
let entry = Entry::new("macaw", "macaw");
let mut client_creation_error: Option<Error> = None;
let mut creds: Option<Creds> = None;
@ -213,21 +215,23 @@ async fn main() -> iced::Result {
let luz_handle2 = luz_handle.clone();
if cfg.auto_connect {
Task::batch([
Task::perform(async move { luz_handle1.get_roster().await }, |result| {
let roster = result.unwrap();
let mut macaw_roster = HashMap::new();
for contact in roster {
macaw_roster.insert(contact.user_jid.clone(), contact);
}
Message::Roster(macaw_roster)
}),
Task::perform(async move { luz_handle2.get_chats().await }, |chats| {
let chats = chats.unwrap();
info!("got chats: {:?}", chats);
Message::GotChats(chats)
}),
Task::batch([
Task::perform(async move { luz_handle1.get_roster().await }, |result| {
let roster = result.unwrap();
let mut macaw_roster = HashMap::new();
for contact in roster {
macaw_roster.insert(contact.user_jid.clone(), contact);
}
Message::Roster(macaw_roster)
}),
Task::perform(async move { luz_handle2.get_chats().await }, |chats| {
let chats = chats.unwrap();
info!("got chats: {:?}", chats);
Message::GotChats(chats)
}),
])
.chain(Task::done(Message::Connect)),
Task::stream(stream),
Task::done(Message::Connect),
])
} else {
Task::batch([
@ -248,20 +252,22 @@ async fn main() -> iced::Result {
])
}
};
iced::application("Macaw", Macaw::update, Macaw::view).run_with(|| {
(
Macaw::new(
Some(Client {
client: luz_handle,
// TODO:
jid,
connection_status: Presence::Offline(Offline::default()),
}),
cfg,
),
task,
)
})
iced::application("Macaw", Macaw::update, Macaw::view)
.theme(Macaw::theme)
.run_with(|| {
(
Macaw::new(
Some(Client {
client: luz_handle,
// TODO:
jid,
connection_status: Presence::Offline(Offline::default()),
}),
cfg,
),
task,
)
})
} else {
if let Some(e) = client_creation_error {
iced::application("Macaw", Macaw::update, Macaw::view)
@ -288,6 +294,7 @@ pub enum Message {
MessageCompose(String),
SendMessage(JID, String),
Error(Error),
MessageView(message_view::Message),
}
#[derive(Debug, Error, Clone)]
@ -298,6 +305,8 @@ pub enum Error {
CredentialsSave(CredentialsSaveError),
#[error("failed to load credentials: {0}")]
CredentialsLoad(CredentialsLoadError),
#[error("failed to retreive messages for chat {0}")]
MessageHistory(JID, CommandError<luz::error::DatabaseError>),
}
#[derive(Debug, Error, Clone)]
@ -381,14 +390,22 @@ impl Macaw {
Task::none()
}
UpdateMessage::Message { to, message } => {
if let Some((_chat, message_history)) = self.chats.get_mut(&to) {
message_history.insert(message.id, message);
if let Some((chat_jid, (chat, old_message))) =
self.chats.shift_remove_entry(&to)
{
self.chats
.insert_before(0, chat_jid, (chat, message.clone()));
if let Some(open_chat) = &mut self.open_chat {
if open_chat.jid == to {
open_chat.update(message_view::Message::Message(message));
}
}
} else {
let chat = Chat {
correspondent: to.clone(),
};
let message_history = indexmap! {message.id => message};
self.chats.insert(to, (chat, message_history));
let message_history = indexmap! {message.id => message.clone()};
self.chats.insert_before(0, to, (chat, message));
}
Task::none()
}
@ -421,8 +438,8 @@ impl Macaw {
info!("got chats: {:?}", chats);
Message::GotChats(chats)
}),
Task::done(Message::Connect),
])
.chain(Task::done(Message::Connect))
} else {
Task::batch([
Task::perform(async move { client1.client.get_roster().await }, |result| {
@ -472,11 +489,23 @@ impl Macaw {
Account::LoggedOut(login_modal) => Task::none(),
},
Message::OpenChat(jid) => {
self.open_chat = Some(OpenChat {
jid,
new_message: String::new(),
});
Task::none()
self.open_chat = Some(MessageView::new(jid.clone()));
let jid1 = jid.clone();
match &self.client {
Account::LoggedIn(client) => {
let client = client.clone();
Task::perform(
async move { client.get_messages(jid1).await },
move |result| match result {
Ok(h) => {
Message::MessageView(message_view::Message::MessageHistory(h))
}
Err(e) => Message::Error(Error::MessageHistory(jid.clone(), e)),
},
)
}
Account::LoggedOut(login_modal) => Task::none(),
}
}
Message::LoginModal(login_modal_message) => match &mut self.client {
Account::LoggedIn(_client) => Task::none(),
@ -567,6 +596,7 @@ impl Macaw {
let client = client.clone();
let correspondent = chat.correspondent.clone();
tasks.push(Task::perform(
// TODO: don't get the entire message history LOL
async move { (chat, client.get_messages(correspondent).await) },
|result| {
let messages: IndexMap<Uuid, ChatMessage> = result
@ -591,9 +621,12 @@ impl Macaw {
// Task::batch(tasks)
// }),
}
Message::GotMessageHistory(chat, message_history) => {
self.chats
.insert(chat.correspondent.clone(), (chat, message_history));
Message::GotMessageHistory(chat, mut message_history) => {
// TODO: don't get the entire message history LOL
if let Some((_id, message)) = message_history.pop() {
self.chats
.insert(chat.correspondent.clone(), (chat, message));
}
Task::none()
}
Message::CloseChat(jid) => {
@ -619,7 +652,23 @@ impl Macaw {
)
.discard()
}
Message::Error(error) => todo!("error notification toasts, logging?"),
Message::Error(error) => {
error!("{}", error);
Task::none()
}
Message::MessageView(message) => {
if let Some(message_view) = &mut self.open_chat {
let action = message_view.update(message);
match action {
message_view::Action::None => Task::none(),
message_view::Action::SendMessage(m) => {
Task::done(Message::SendMessage(message_view.jid.clone(), m))
}
}
} else {
Task::none()
}
}
}
}
@ -670,34 +719,12 @@ impl Macaw {
let message_view;
if let Some(open_chat) = &self.open_chat {
let (chat, messages) = self.chats.get(&open_chat.jid).unwrap();
let mut messages_view = column![];
for (_id, message) in messages {
let from: Cow<'_, str> = (&message.from).into();
let message: Column<Message> =
column![text(from).size(12), text(&message.body.body)].into();
messages_view = messages_view.push(message);
}
let message_send_input = row![
text_input("new message", &open_chat.new_message)
.on_input(Message::MessageCompose),
button("send").on_press(Message::SendMessage(
chat.correspondent.clone(),
open_chat.new_message.clone()
))
];
message_view = column![
scrollable(messages_view)
.height(Fill)
.width(Fill)
.anchor_bottom(),
message_send_input
];
message_view = open_chat.view().map(Message::MessageView)
} else {
message_view = column![];
message_view = column![].into();
}
row![sidebar, message_view.width(Fill)]
row![sidebar, container(message_view).width(Fill)]
// old
@ -731,7 +758,117 @@ impl Macaw {
}
fn theme(&self) -> Theme {
Theme::Dark
let extended = Extended {
background: Background {
base: Pair {
color: color!(0x392c25),
text: color!(0xdcdcdc),
},
weakest: Pair {
color: color!(0xdcdcdc),
text: color!(0x392c25),
},
weak: Pair {
color: color!(0xdcdcdc),
text: color!(0x392c25),
},
strong: Pair {
color: color!(0x364b3b),
text: color!(0xdcdcdc),
},
strongest: Pair {
color: color!(0x364b3b),
text: color!(0xdcdcdc),
},
},
primary: Primary {
base: Pair {
color: color!(0x2b33b4),
text: color!(0xdcdcdc),
},
weak: Pair {
color: color!(0x4D4A5E),
text: color!(0xdcdcdc),
},
strong: Pair {
color: color!(0x2b33b4),
text: color!(0xdcdcdc),
},
},
secondary: Secondary {
base: Pair {
color: color!(0xffce07),
text: color!(0x000000),
},
weak: Pair {
color: color!(0xffce07),
text: color!(0x000000),
},
strong: Pair {
color: color!(0xffce07),
text: color!(0x000000),
},
},
success: Success {
base: Pair {
color: color!(0x14802E),
text: color!(0xdcdcdc),
},
weak: Pair {
color: color!(0x14802E),
text: color!(0xdcdcdc),
},
strong: Pair {
color: color!(0x14802E),
text: color!(0xdcdcdc),
},
},
warning: Warning {
base: Pair {
color: color!(0xFF9D00),
text: color!(0x000000),
},
weak: Pair {
color: color!(0xFF9D00),
text: color!(0x000000),
},
strong: Pair {
color: color!(0xFF9D00),
text: color!(0x000000),
},
},
danger: Danger {
base: Pair {
color: color!(0xC1173C),
text: color!(0xdcdcdc),
},
weak: Pair {
color: color!(0xC1173C),
text: color!(0xdcdcdc),
},
strong: Pair {
color: color!(0xC1173C),
text: color!(0xdcdcdc),
},
},
is_dark: true,
};
Theme::Custom(Arc::new(Custom::with_fn(
"macaw".to_string(),
Palette::DARK,
|_| extended,
)))
// Theme::Custom(Arc::new(Custom::new(
// "macaw".to_string(),
// Palette {
// background: color!(0x392c25),
// text: color!(0xdcdcdc),
// primary: color!(0x2b33b4),
// success: color!(0x14802e),
// warning: color!(0xffce07),
// danger: color!(0xc1173c),
// },
// )))
}
}

169
src/message_view.rs Normal file
View File

@ -0,0 +1,169 @@
use std::borrow::Cow;
use chrono::NaiveDate;
use iced::{
alignment::Horizontal::{self, Right},
border::Radius,
color,
theme::Palette,
widget::{button, column, container, row, scrollable, text, text_input, Column},
Border, Color, Element,
Length::{Fill, Shrink},
Theme,
};
use indexmap::IndexMap;
use jid::JID;
use luz::chat::Message as ChatMessage;
use uuid::Uuid;
pub struct MessageView {
pub jid: JID,
pub message_history: IndexMap<Uuid, ChatMessage>,
pub new_message: String,
}
#[derive(Debug, Clone)]
pub enum Message {
MessageHistory(Vec<ChatMessage>),
Message(ChatMessage),
MessageCompose(String),
SendMessage(String),
}
pub enum Action {
None,
SendMessage(String),
}
impl MessageView {
pub fn new(jid: JID) -> Self {
Self {
jid,
// TODO: save position in message history
message_history: IndexMap::new(),
// TODO: save draft (as part of chat struct?)
new_message: String::new(),
}
}
pub fn update(&mut self, message: Message) -> Action {
match message {
Message::MessageHistory(messages) => {
if self.message_history.is_empty() {
self.message_history = messages
.into_iter()
.map(|message| (message.id.clone(), message))
.collect();
}
Action::None
}
Message::Message(message) => {
let i = self
.message_history
.iter()
.position(|(_id, m)| m.timestamp > message.timestamp);
if let Some(i) = i {
self.message_history.insert_before(i, message.id, message);
} else {
self.message_history.insert(message.id, message);
}
Action::None
}
Message::MessageCompose(s) => {
self.new_message = s;
Action::None
}
Message::SendMessage(m) => {
self.new_message = String::new();
Action::SendMessage(m)
}
}
}
pub fn view(&self) -> Element<Message> {
let mut messages_view = column![].spacing(8);
let mut latest_date = NaiveDate::MIN;
for (_id, message) in &self.message_history {
let message_date = message.timestamp.naive_local().date();
if message_date > latest_date {
latest_date = message_date;
messages_view = messages_view.push(date(latest_date));
}
messages_view = messages_view.push(self.message(message));
}
let message_send_input = row![
text_input("new message", &self.new_message).on_input(Message::MessageCompose),
button("send").on_press(Message::SendMessage(self.new_message.clone()))
];
column![
scrollable(messages_view)
.height(Fill)
.width(Fill)
.spacing(1)
.anchor_bottom(),
message_send_input
]
.into()
}
pub fn message<'a>(&'a self, message: &'a ChatMessage) -> Element<'a, Message> {
let timestamp = message.timestamp.naive_local();
let timestamp = timestamp.time().format("%H:%M").to_string();
if self.jid == message.from.as_bare() {
container(
container(
column![
text(message.body.body.as_str()),
container(text(timestamp).wrapping(text::Wrapping::None).size(12))
.align_right(Fill)
]
.width(Shrink)
.max_width(500),
)
.padding(16)
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style::default()
.background(palette.primary.weak.color)
.border(Border {
color: Color::BLACK,
width: 4.,
radius: Radius::new(16),
})
}),
)
.align_left(Fill)
.into()
} else {
let element: Element<Message> = container(
container(
column![
text(message.body.body.as_str()),
container(text(timestamp).wrapping(text::Wrapping::None).size(12))
.align_right(Fill)
]
.width(Shrink)
.max_width(500),
)
.padding(16)
.style(|theme: &Theme| {
let palette = theme.extended_palette();
container::Style::default()
.background(palette.primary.base.color)
.border(Border {
color: Color::BLACK,
width: 4.,
radius: Radius::new(16),
})
}),
)
.align_right(Fill)
.into();
// element.explain(Color::BLACK)
element
}
}
}
pub fn date(date: NaiveDate) -> Element<'static, Message> {
container(text(date.to_string())).center_x(Fill).into()
}