feat: message view
This commit is contained in:
parent
d0e1226559
commit
9bfb143c32
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
|
|
2
ideas.md
2
ideas.md
|
@ -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
|
||||
|
|
291
src/main.rs
291
src/main.rs
|
@ -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),
|
||||
// },
|
||||
// )))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
Loading…
Reference in New Issue