Intial image support, proper time formatting

Also feature gate unix exclusive code
This commit is contained in:
Amanda Graven 2021-01-09 19:11:10 +01:00
parent 2a9dbb90d3
commit f25d3b6821
Signed by: amanda
GPG key ID: 45C461CDC9286390
4 changed files with 315 additions and 132 deletions

View file

@ -8,22 +8,22 @@ edition = "2018"
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
crossbeam-channel = "0.4"
async-stream = "0.3"
dirs-next = "2.0"
futures = "0.3"
iced = { version = "0.2", features = ["debug", "tokio_old", "image"] }
iced_futures = "0.2"
hostname = "0.3"
iced = { git = "https://github.com/hecrj/iced", rev = "31522e3", features = ["debug", "image", "tokio"] }
iced_futures = { git = "https://github.com/hecrj/iced", rev = "31522e3" }
#iced_glow = { git = "https://github.com/hecrj/iced", rev = "31522e3", features = ["image"] }
#matrix-sdk-common-macros = { git = "https://github.com/matrix-org/matrix-rust-sdk", rev = "e65915e" }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "0.2", features = ["sync"] }
time = "0.2"
tokio = { version = "1.0", features = ["sync"] }
toml = "0.5"
tracing-subscriber = { version = "0.2", features = ["parking_lot"] }
[dependencies.matrix-sdk]
git = "https://github.com/matrix-org/matrix-rust-sdk"
rev = "74998c8"
rev = "6435269"
default_features = false
features = ["encryption", "sqlite_cryptostore", "messages", "rustls-tls", "unstable-synapse-quirks"]

View file

@ -1,8 +1,7 @@
extern crate crossbeam_channel as channel;
extern crate dirs_next as dirs;
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
use iced::Application;
@ -16,6 +15,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Make sure config dir exists and is not accessible by other users.
if !config_dir.is_dir() {
std::fs::create_dir(&config_dir)?;
#[cfg(unix)]
std::fs::set_permissions(&config_dir, Permissions::from_mode(0o700))?;
}

View file

@ -1,9 +1,16 @@
use std::time::{Duration, SystemTime};
use std::{
convert::TryFrom,
time::{Duration, SystemTime},
};
use async_stream::stream;
use matrix_sdk::{
api::r0::{account::register::Request as RegistrationRequest, uiaa::AuthData},
events::{AnyRoomEvent, AnySyncRoomEvent, AnyToDeviceEvent},
identifiers::{DeviceId, EventId, UserId},
events::{
room::message::{MessageEvent, MessageEventContent},
AnyMessageEvent, AnyRoomEvent, AnySyncRoomEvent, AnyToDeviceEvent,
},
identifiers::{DeviceId, EventId, ServerName, UserId},
reqwest::Url,
Client, ClientConfig, LoopCtrl, SyncSettings,
};
@ -67,6 +74,7 @@ pub async fn signup(
});
let response = client.register(request).await?;
client.sync_once(SyncSettings::new()).await?;
let session = Session {
access_token: response.access_token.unwrap(),
@ -103,7 +111,7 @@ pub async fn login(
homeserver: server.to_owned(),
};
write_session(&session)?;
//client.sync_once(SyncSettings::new()).await?;
client.sync_once(SyncSettings::new()).await?;
Ok((client, session))
}
@ -150,6 +158,19 @@ fn write_session(session: &Session) -> Result<(), Error> {
Ok(())
}
/// Break down an mxc url to its authority and path
pub fn parse_mxc(url: &str) -> Result<(Box<ServerName>, String), Error> {
let url = Url::parse(&url)?;
anyhow::ensure!(url.scheme() == "mxc", "Not an mxc url");
let host = url.host_str().ok_or(anyhow::anyhow!("url"))?;
let server_name: Box<ServerName> = <&ServerName>::try_from(host)?.into();
let path = url.path_segments().and_then(|mut p| p.next());
match path {
Some(path) => Ok((server_name, path.to_owned())),
_ => Err(anyhow::anyhow!("Invalid mxc url")),
}
}
pub struct MatrixSync {
client: matrix_sdk::Client,
join: Option<tokio::task::JoinHandle<()>>,
@ -162,21 +183,6 @@ impl MatrixSync {
}
}
/*#[async_trait]
impl EventEmitter for Callback {
async fn on_room_message(&self, room: SyncRoom, event: &SyncMessageEvent<MessageEventContent>) {
let room_id = if let matrix_sdk::RoomState::Joined(arc) = room {
let room = arc.read().await;
room.room_id.clone()
} else {
return;
};
self.sender
.send(event.clone().into_full_event(room_id))
.ok();
}
}*/
#[derive(Clone, Debug)]
pub enum Event {
Room(AnyRoomEvent),
@ -201,13 +207,13 @@ where
mut self: Box<Self>,
_input: iced_futures::BoxStream<I>,
) -> iced_futures::BoxStream<Self::Output> {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let client = self.client.clone();
let join = tokio::task::spawn(async move {
client
.sync_with_callback(
SyncSettings::new()
//.token(client.sync_token().await.unwrap())
.token(client.sync_token().await.unwrap())
.timeout(Duration::from_secs(90))
.full_state(true),
|response| async {
@ -241,14 +247,22 @@ where
.await;
});
self.join = Some(join);
Box::pin(receiver)
let stream = stream! {
while let Some(item) = receiver.recv().await {
yield item;
}
};
Box::pin(stream)
}
}
pub trait AnyRoomEventExt {
/// Gets the event id of the underlying event
fn event_id(&self) -> &EventId;
/// Gets the ´origin_server_ts` member of the underlying event
fn origin_server_ts(&self) -> SystemTime;
/// Gets the mxc url in a message event if there is noe
fn image_url(&self) -> Option<String>;
}
impl AnyRoomEventExt for AnyRoomEvent {
@ -269,4 +283,26 @@ impl AnyRoomEventExt for AnyRoomEvent {
}
.to_owned()
}
fn image_url(&self) -> Option<String> {
match self {
AnyRoomEvent::Message(message) => message.image_url(),
_ => None,
}
}
}
pub trait AnyMessageEventExt {
fn image_url(&self) -> Option<String>;
}
impl AnyMessageEventExt for AnyMessageEvent {
fn image_url(&self) -> Option<String> {
match self {
AnyMessageEvent::RoomMessage(MessageEvent {
content: MessageEventContent::Image(ref image),
..
}) => image.url.clone(),
_ => None,
}
}
}

347
src/ui.rs
View file

@ -5,22 +5,23 @@ use std::{
use futures::executor::block_on;
use iced::{
Application, Button, Column, Command, Container, Element, Length, Row, Rule, Scrollable,
Align, Application, Button, Column, Command, Container, Element, Length, Row, Rule, Scrollable,
Subscription, Text, TextInput,
};
use matrix_sdk::{
api::r0::message::get_message_events::{
Request as MessageRequest, Response as MessageResponse,
api::r0::{
media::get_content::Request as ImageRequest,
message::get_message_events::{Request as MessageRequest, Response as MessageResponse},
},
events::{
key::verification::cancel::CancelCode as VerificationCancelCode,
room::message::MessageEventContent, AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent,
AnyStateEvent, AnyToDeviceEvent,
room::{member::MembershipState, message::MessageEventContent},
AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, AnyStateEvent, AnyToDeviceEvent,
},
identifiers::{EventId, RoomAliasId, RoomId, UserId},
};
use crate::matrix::{self, AnyRoomEventExt};
use crate::matrix::{self, AnyMessageEventExt, AnyRoomEventExt};
pub mod prompt;
pub mod settings;
@ -39,31 +40,28 @@ pub enum RoomSorting {
#[derive(Clone, Debug, Default)]
pub struct RoomEntry {
/// Cached calculated name
name: String,
pub name: String,
/// Room topic
topic: String,
pub topic: String,
/// Canonical alias
alias: Option<RoomAliasId>,
pub alias: Option<RoomAliasId>,
/// Defined display name
display_name: Option<String>,
pub display_name: Option<String>,
/// Person we're in a direct message with
direct: Option<UserId>,
pub direct: Option<UserId>,
/// Cache of messages
messages: MessageBuffer,
pub messages: MessageBuffer,
}
impl RoomEntry {
/// Recalculate displayname
pub fn update_display_name(&mut self, id: &RoomId) {
self.name = if let Some(ref name) = self.display_name {
name.to_owned()
} else if let Some(ref user) = self.direct {
user.to_string()
} else if let Some(ref alias) = self.alias {
alias.to_string()
} else {
id.to_string()
};
pub fn from_sdk(room: &matrix_sdk::JoinedRoom) -> Self {
Self {
direct: room.direct_target(),
name: block_on(async { room.display_name().await }),
topic: room.topic().unwrap_or_default(),
alias: room.canonical_alias(),
..Default::default()
}
}
}
@ -82,6 +80,8 @@ pub struct MessageBuffer {
end: Option<String>,
/// Most recent activity in the room
updated: std::time::SystemTime,
/// Whether we're awaiting for backfill to be received
loading: bool,
}
impl MessageBuffer {
@ -117,6 +117,13 @@ impl MessageBuffer {
self.sort();
self.update_time();
}
/// Whather the message buffer has the room creation event
pub fn has_beginning(&self) -> bool {
self.messages
.iter()
.any(|e| matches!(e, AnyRoomEvent::State(AnyStateEvent::RoomCreate(_))))
}
}
impl Default for MessageBuffer {
@ -127,6 +134,7 @@ impl Default for MessageBuffer {
start: None,
end: None,
updated: SystemTime::UNIX_EPOCH,
loading: false,
}
}
}
@ -153,6 +161,8 @@ pub struct MainView {
sorting: RoomSorting,
/// Room state
rooms: BTreeMap<RoomId, RoomEntry>,
/// A map of mxc urls to image data
images: BTreeMap<String, iced::image::Handle>,
/// Room list entries for direct conversations
dm_buttons: Vec<iced::button::State>,
@ -162,6 +172,10 @@ pub struct MainView {
room_scroll: iced::scrollable::State,
/// Message view scrollbar state
message_scroll: iced::scrollable::State,
/// Backfill fetch button state
backfill_button: iced::button::State,
/// Button to go the room a tombstone points to
tombstone_button: iced::button::State,
/// Message draft text input
message_input: iced::text_input::State,
/// Button to send drafted message
@ -186,8 +200,11 @@ impl MainView {
sas: None,
rooms: Default::default(),
selected: None,
images: Default::default(),
room_scroll: Default::default(),
message_scroll: Default::default(),
backfill_button: Default::default(),
tombstone_button: Default::default(),
message_input: Default::default(),
dm_buttons: Vec::new(),
group_buttons: Vec::new(),
@ -245,7 +262,12 @@ impl MainView {
.map(|(idx, button)| {
// TODO: highlight selected
let (id, room) = dm_rooms[idx];
Button::new(button, Text::new(&room.name))
let name = if room.name.is_empty() {
"Missing name"
} else {
&room.name
};
Button::new(button, Text::new(name))
.width(300.into())
.on_press(Message::SelectRoom(id.to_owned()))
})
@ -281,10 +303,17 @@ impl MainView {
let mut message_col = Column::new().spacing(5).padding(5);
let selected_room = match self.selected {
Some(ref selected) => self.rooms.get(selected),
Some(ref selected) => match (
self.rooms.get(selected),
self.client.get_joined_room(selected),
) {
(Some(room), Some(joined)) => Some((room, joined)),
_ => None,
},
None => None,
};
if let Some(room) = selected_room {
if let Some((room, joined)) = selected_room {
// Include user id or canonical alias in title when appropriate
let title = if let Some(ref direct) = room.direct {
format!("{} ({})", &room.name, direct)
} else if let Some(ref alias) = room.alias {
@ -292,29 +321,43 @@ impl MainView {
} else {
room.name.clone()
};
message_col = message_col
.push(Text::new(title).size(25))
.push(Rule::horizontal(2));
let mut scroll = Scrollable::new(&mut self.message_scroll)
.scrollbar_width(2)
.height(Length::Fill);
// Backfill button or loading message
let backfill: Element<_> = if room.messages.loading {
Text::new("Loading...").into()
} else if room.messages.has_beginning() {
let creation = joined.create_content().unwrap();
let mut col =
Column::new().push(Text::new("This is the beginning of room history"));
if let Some(prevous) = creation.predecessor {
col = col.push(
Button::new(&mut self.backfill_button, Text::new("Go to older version"))
.on_press(Message::SelectRoom(prevous.room_id)),
);
}
col.into()
} else {
Button::new(&mut self.backfill_button, Text::new("Load more messages"))
.on_press(Message::BackFill(self.selected.clone().unwrap()))
.into()
};
scroll = scroll.push(Container::new(backfill).width(Length::Fill).center_x());
// Messages
for event in room.messages.messages.iter() {
#[allow(clippy::single_match)]
match event {
AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(message)) => {
let sender = {
match self.client.get_joined_room(&message.room_id) {
Some(backend) => {
match block_on(async {
backend.get_member(&message.sender).await
}) {
Some(member) => member.name().to_owned(),
None => message.sender.to_string(),
}
}
let sender =
match block_on(async { joined.get_member(&message.sender).await }) {
Some(member) => member.name().to_owned(),
None => message.sender.to_string(),
}
};
};
let content: Element<_> = match &message.content {
MessageEventContent::Audio(audio) => {
Text::new(format!("Audio message: {}", audio.body))
@ -323,7 +366,7 @@ impl MainView {
.into()
}
MessageEventContent::Emote(emote) => {
Text::new(format!("{} {}", sender, emote.body))
Text::new(format!("* {} {}", sender, emote.body))
.width(Length::Fill)
.into()
}
@ -334,9 +377,24 @@ impl MainView {
.into()
}
MessageEventContent::Image(image) => {
Text::new(format!("Image with description: {}", image.body))
.width(Length::Fill)
.into()
if let Some(ref url) = image.url {
match self.images.get(url) {
Some(handle) => Container::new(
iced::Image::new(handle.to_owned())
.width(800.into())
.height(1200.into()),
)
.width(Length::Fill)
.into(),
None => {
Text::new("Image not loaded").width(Length::Fill).into()
}
}
} else {
Text::new("Encrypted images not supported yet")
.width(Length::Fill)
.into()
}
}
MessageEventContent::Notice(notice) => {
Text::new(&notice.body).width(Length::Fill).into()
@ -367,6 +425,26 @@ impl MainView {
_ => (),
}
}
// Tombstone
if let Some(tombstone) = joined.tombstone() {
let text = Text::new(format!(
"This room has been upgraded to a new version: {}",
tombstone.body
));
let button =
Button::new(&mut self.tombstone_button, Text::new("Go to upgraded room"))
.on_press(Message::SelectRoom(tombstone.replacement_room));
scroll = scroll.push(
Container::new(
Column::new()
.push(text)
.push(button)
.align_items(Align::Center),
)
.center_x()
.width(Length::Fill),
);
}
message_col = message_col.push(scroll);
} else {
message_col = message_col.push(
@ -490,6 +568,10 @@ pub enum Message {
BackFill(RoomId),
/// Received backfille
BackFilled(RoomId, MessageResponse),
/// Fetch an image pointed to by an mxc url
FetchImage(String),
/// Fetched an image
FetchedImage(String, iced::image::Handle),
/// View messages from this room
SelectRoom(RoomId),
/// Set error message
@ -637,48 +719,25 @@ impl Application for Retrix {
*self = Retrix::LoggedIn(MainView::new(client.clone(), session));
let mut commands: Vec<Command<Message>> = Vec::new();
for room in client.joined_rooms().into_iter() {
let command = async move {
//let client = client.clone();
let command: Command<_> = async move {
let room = room.clone();
let entry = RoomEntry {
direct: room.direct_target(),
name: block_on(async { room.calculate_name().await }),
topic: room.topic().unwrap_or_default(),
..RoomEntry::default()
};
let entry = RoomEntry::from_sdk(&room);
// Display name calculation for DMs is bronk so we're doing it
// ourselves
/*match entry.direct {
Some(ref direct) => {
let request = matrix_sdk::api::r0::profile::get_display_name::Request::new(direct);
if let Ok(response) = client.send(request).await {
if let Some(name) = response.displayname {
entry.name = name;
}
/*if let Some(ref direct) = entry.direct {
let request = DisplayNameRequest::new(direct);
if let Ok(response) = client.send(request).await {
if let Some(name) = response.displayname {
entry.name = name;
}
}
None => entry.name = room.display_name(),
}*/
/*let messages = room
.messages
.iter()
.cloned()
.map(|event| match event {
AnyPossiblyRedactedSyncMessageEvent::Redacted(e) => {
AnyRoomEvent::RedactedMessage(
e.into_full_event(id.clone()),
)
}
AnyPossiblyRedactedSyncMessageEvent::Regular(e) => {
AnyRoomEvent::Message(e.into_full_event(id.clone()))
}
})
.collect();
entry.messages.messages = messages;*/
Message::ResetRoom(room.room_id().to_owned(), entry)
}
.into();
commands.push(command)
commands.push(command);
}
return Command::batch(commands);
}
@ -689,38 +748,57 @@ impl Application for Retrix {
Message::ClearError => view.error = None,
Message::SetSort(s) => view.sorting = s,
Message::ResetRoom(id, room) => {
view.rooms.insert(id, room).and(Some(())).unwrap_or(())
view.rooms.insert(id.clone(), room);
return async move { Message::BackFill(id) }.into();
}
Message::SelectRoom(r) => {
view.selected = Some(r.clone());
return async move { Message::BackFill(r) }.into();
if view.rooms.get(&r).unwrap().messages.messages.is_empty() {
return async move { Message::BackFill(r) }.into();
}
}
Message::Sync(event) => match event {
matrix::Event::Room(event) => match event {
AnyRoomEvent::Message(event) => {
let room = view.rooms.entry(event.room_id().clone()).or_default();
room.messages.push(AnyRoomEvent::Message(event.clone()));
let mut commands = Vec::new();
let img_cmd = match event.image_url() {
Some(url) => async { Message::FetchImage(url) }.into(),
None => Command::none(),
};
commands.push(img_cmd);
// Set read marker if message is in selected room
if view.selected.as_ref() == Some(event.room_id()) {
// Super duper gross ugly scroll to bottom hack
/*view.message_scroll = unsafe {
let mut tmp = std::mem::transmute::<_, (Option<f32>, f32)>(
view.message_scroll,
);
tmp.1 = 999999.0;
std::mem::transmute::<_, iced::scrollable::State>(tmp)
};*/
let client = view.client.clone();
return Command::perform(
async move {
client
.read_marker(
event.room_id(),
event.event_id(),
Some(event.event_id()),
)
.await
.err()
},
|result| match result {
let marker_cmd = async move {
let result = client
.read_marker(
event.room_id(),
event.event_id(),
Some(event.event_id()),
)
.await
.err();
match result {
Some(err) => Message::ErrorMessage(err.to_string()),
// TODO: Make this an actual no-op
None => Message::Login,
},
);
}
}
.into();
commands.push(marker_cmd);
}
return Command::batch(commands);
}
AnyRoomEvent::State(event) => match event {
AnyStateEvent::RoomCanonicalAlias(ref alias) => {
@ -731,6 +809,9 @@ impl Application for Retrix {
AnyStateEvent::RoomName(ref name) => {
let room = view.rooms.entry(name.room_id.clone()).or_default();
room.display_name = name.content.name().map(String::from);
if let Some(joined) = view.client.get_joined_room(&name.room_id) {
room.name = block_on(async { joined.display_name().await });
}
room.messages.push(AnyRoomEvent::State(event));
}
AnyStateEvent::RoomTopic(ref topic) => {
@ -738,6 +819,36 @@ impl Application for Retrix {
room.topic = topic.content.topic.clone();
room.messages.push(AnyRoomEvent::State(event));
}
AnyStateEvent::RoomCreate(ref create) => {
// Add room to the entry list
let room = match view.client.get_joined_room(&create.room_id) {
Some(joined) => view
.rooms
.entry(create.room_id.clone())
.or_insert_with(|| RoomEntry::from_sdk(&joined)),
None => view.rooms.entry(create.room_id.clone()).or_default(),
};
room.messages.push(AnyRoomEvent::State(event));
}
AnyStateEvent::RoomMember(ref member) => {
let room = view.rooms.entry(member.room_id.clone()).or_default();
let client = view.client.clone();
// If we left a room, remove it from the RoomEntry list
let own_id = block_on(async { client.user_id().await })
.unwrap()
.to_string();
if member.content.membership == MembershipState::Leave
&& member.state_key == own_id
{
// Deselect room if we're leaving selected room
if view.selected.as_ref() == Some(&member.room_id) {
view.selected = None;
}
view.rooms.remove(&member.room_id);
return Command::none();
}
room.messages.push(AnyRoomEvent::State(event));
}
ref any => {
// Ensure room exists
let room = view.rooms.entry(any.room_id().clone()).or_default();
@ -768,7 +879,8 @@ impl Application for Retrix {
}
},
Message::BackFill(id) => {
let room = view.rooms.get(&id).unwrap();
let room = view.rooms.entry(id.clone()).or_default();
room.messages.loading = true;
let client = view.client.clone();
let token = match room.messages.end.clone() {
Some(end) => end,
@ -789,6 +901,7 @@ impl Application for Retrix {
}
Message::BackFilled(id, response) => {
let room = view.rooms.get_mut(&id).unwrap();
room.messages.loading = false;
let events: Vec<AnyRoomEvent> = response
.chunk
.into_iter()
@ -807,7 +920,41 @@ impl Application for Retrix {
if let Some(end) = response.end {
room.messages.end = Some(end);
}
let commands: Vec<Command<_>> = events
.iter()
.filter_map(|e| e.image_url())
.map(|url| async { Message::FetchImage(url) }.into())
.collect();
room.messages.append(events);
return Command::batch(commands);
}
Message::FetchImage(url) => {
let (server, path) = match matrix::parse_mxc(&url) {
Ok((server, path)) => (server, path),
Err(e) => {
return async move { Message::ErrorMessage(e.to_string()) }.into()
}
};
println!(
"Getting '{}' from '{}', with url '{}'",
&server, &path, &url
);
let client = view.client.clone();
return async move {
let request = ImageRequest::new(&path, &*server);
let response = client.send(request).await;
match response {
Ok(response) => Message::FetchedImage(
url,
iced::image::Handle::from_memory(response.file),
),
Err(e) => Message::ErrorMessage(e.to_string()),
}
}
.into();
}
Message::FetchedImage(url, handle) => {
view.images.insert(url, handle);
}
Message::SetVerification(v) => view.sas = v,
Message::VerificationAccept => {
@ -965,13 +1112,13 @@ impl Application for Retrix {
}
fn format_systime(time: std::time::SystemTime) -> String {
let secs = time
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!(
"{:02}:{:02}",
(secs % (60 * 60 * 24)) / (60 * 60),
(secs % (60 * 60)) / 60
)
let offset = time::UtcOffset::try_current_local_offset().unwrap_or(time::UtcOffset::UTC);
let time = time::OffsetDateTime::from(time).to_offset(offset);
let today = time::OffsetDateTime::now_utc().to_offset(offset).date();
// Display
if time.date() == today {
time.format("%T")
} else {
time.format("%F %T")
}
}