You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
644 lines
21 KiB
644 lines
21 KiB
use crate::app::App; |
|
use crate::i18n::i18n; |
|
use fractal_api::clone; |
|
use itertools::Itertools; |
|
use log::info; |
|
|
|
use chrono::prelude::*; |
|
use glib; |
|
use gtk; |
|
use gtk::prelude::*; |
|
use pango; |
|
use url::Url; |
|
|
|
use crate::backend::BKCommand; |
|
|
|
use crate::util::markup_text; |
|
|
|
use std::sync::mpsc::channel; |
|
use std::sync::mpsc::TryRecvError; |
|
use std::sync::mpsc::{Receiver, Sender}; |
|
|
|
use crate::cache::download_to_cache; |
|
use crate::cache::download_to_cache_username; |
|
use crate::cache::download_to_cache_username_emote; |
|
|
|
use crate::globals; |
|
use crate::uitypes::MessageContent as Message; |
|
use crate::uitypes::RowType; |
|
use crate::widgets; |
|
use crate::widgets::message_menu::MessageMenu; |
|
use crate::widgets::AvatarExt; |
|
|
|
/* A message row in the room history */ |
|
#[derive(Clone, Debug)] |
|
pub struct MessageBox { |
|
backend: Sender<BKCommand>, |
|
server_url: Url, |
|
username: gtk::Label, |
|
pub username_event_box: gtk::EventBox, |
|
eventbox: gtk::EventBox, |
|
gesture: gtk::GestureLongPress, |
|
row: gtk::ListBoxRow, |
|
image: Option<gtk::DrawingArea>, |
|
header: bool, |
|
} |
|
|
|
impl MessageBox { |
|
pub fn new(backend: Sender<BKCommand>, server_url: Url) -> MessageBox { |
|
let username = gtk::Label::new(None); |
|
let eb = gtk::EventBox::new(); |
|
let eventbox = gtk::EventBox::new(); |
|
let row = gtk::ListBoxRow::new(); |
|
let gesture = gtk::GestureLongPress::new(&eventbox); |
|
|
|
username.set_ellipsize(pango::EllipsizeMode::End); |
|
gesture.set_propagation_phase(gtk::PropagationPhase::Capture); |
|
gesture.set_touch_only(true); |
|
|
|
MessageBox { |
|
backend, |
|
server_url, |
|
username, |
|
username_event_box: eb, |
|
eventbox, |
|
gesture, |
|
row, |
|
image: None, |
|
header: true, |
|
} |
|
} |
|
|
|
/* create the message row with or without a header */ |
|
pub fn create(&mut self, msg: &Message, has_header: bool) { |
|
self.set_msg_styles(msg, &self.row); |
|
self.row.set_selectable(false); |
|
let w = if has_header && msg.mtype != RowType::Emote { |
|
self.row.set_margin_top(12); |
|
self.header = true; |
|
self.widget(msg) |
|
} else { |
|
if let RowType::Emote = msg.mtype { |
|
self.row.set_margin_top(12); |
|
} |
|
self.header = false; |
|
self.small_widget(msg) |
|
}; |
|
|
|
self.eventbox.add(&w); |
|
self.row.add(&self.eventbox); |
|
self.row.show_all(); |
|
self.connect_right_click_menu(msg, None); |
|
} |
|
|
|
// FIXME: return directly row |
|
pub fn get_listbox_row(&self) -> Option<>k::ListBoxRow> { |
|
Some(&self.row) |
|
} |
|
|
|
pub fn tmpwidget(mut self, msg: &Message) -> Option<MessageBox> { |
|
self.create(msg, true); |
|
{ |
|
let w = self.get_listbox_row()?; |
|
w.get_style_context().add_class("msg-tmp"); |
|
} |
|
Some(self) |
|
} |
|
|
|
fn widget(&mut self, msg: &Message) -> gtk::Box { |
|
// msg |
|
// +--------+---------+ |
|
// | avatar | content | |
|
// +--------+---------+ |
|
let msg_widget = gtk::Box::new(gtk::Orientation::Horizontal, 10); |
|
let content = self.build_room_msg_content(msg, false); |
|
/* Todo: make build_room_msg_avatar() faster (currently ~1ms) */ |
|
let avatar = self.build_room_msg_avatar(msg); |
|
|
|
msg_widget.pack_start(&avatar, false, false, 0); |
|
msg_widget.pack_start(&content, true, true, 0); |
|
|
|
msg_widget |
|
} |
|
|
|
fn small_widget(&mut self, msg: &Message) -> gtk::Box { |
|
// msg |
|
// +--------+---------+ |
|
// | | content | |
|
// +--------+---------+ |
|
let msg_widget = gtk::Box::new(gtk::Orientation::Horizontal, 5); |
|
let content = self.build_room_msg_content(msg, true); |
|
content.set_margin_start(50); |
|
|
|
msg_widget.pack_start(&content, true, true, 0); |
|
|
|
msg_widget |
|
} |
|
|
|
fn build_room_msg_content(&mut self, msg: &Message, small: bool) -> gtk::Box { |
|
// content |
|
// +------+ |
|
// | info | |
|
// +------+ |
|
// | body | |
|
// +------+ |
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 0); |
|
|
|
if !small { |
|
let info = self.build_room_msg_info(msg); |
|
info.set_margin_top(2); |
|
info.set_margin_bottom(3); |
|
content.pack_start(&info, false, false, 0); |
|
} |
|
|
|
let body = match msg.mtype { |
|
RowType::Sticker => self.build_room_msg_sticker(msg), |
|
RowType::Image => self.build_room_msg_image(msg), |
|
RowType::Emote => self.build_room_msg_emote(msg), |
|
RowType::Audio => self.build_room_audio_player(msg), |
|
RowType::Video | RowType::File => self.build_room_msg_file(msg), |
|
_ => self.build_room_msg_body(msg), |
|
}; |
|
|
|
content.pack_start(&body, true, true, 0); |
|
|
|
content |
|
} |
|
|
|
fn build_room_msg_avatar(&self, msg: &Message) -> widgets::Avatar { |
|
let uid = msg.sender.clone(); |
|
let alias = msg.sender_name.clone(); |
|
let avatar = widgets::Avatar::avatar_new(Some(globals::MSG_ICON_SIZE)); |
|
|
|
let data = avatar.circle( |
|
uid.clone(), |
|
alias.clone(), |
|
globals::MSG_ICON_SIZE, |
|
None, |
|
None, |
|
); |
|
if let Some(name) = alias { |
|
self.username.set_text(&name); |
|
} else { |
|
self.username.set_text(&uid); |
|
} |
|
|
|
download_to_cache( |
|
self.backend.clone(), |
|
self.server_url.clone(), |
|
uid.clone(), |
|
data.clone(), |
|
); |
|
download_to_cache_username( |
|
self.backend.clone(), |
|
self.server_url.clone(), |
|
&uid, |
|
self.username.clone(), |
|
Some(data.clone()), |
|
); |
|
|
|
avatar |
|
} |
|
|
|
fn build_room_msg_username(&self, sender: &str) -> gtk::Label { |
|
let uname = String::from(sender); |
|
|
|
self.username.set_text(&uname); |
|
self.username.set_justify(gtk::Justification::Left); |
|
self.username.set_halign(gtk::Align::Start); |
|
self.username.get_style_context().add_class("username"); |
|
|
|
self.username.clone() |
|
} |
|
|
|
/* Add classes to the widget based on message type */ |
|
fn set_msg_styles(&self, msg: &Message, w: >k::ListBoxRow) { |
|
let style = w.get_style_context(); |
|
match msg.mtype { |
|
RowType::Mention => style.add_class("msg-mention"), |
|
RowType::Emote => style.add_class("msg-emote"), |
|
RowType::Emoji => style.add_class("msg-emoji"), |
|
_ => {} |
|
} |
|
} |
|
|
|
fn set_label_styles(&self, w: >k::Label) { |
|
w.set_line_wrap(true); |
|
w.set_line_wrap_mode(pango::WrapMode::WordChar); |
|
w.set_justify(gtk::Justification::Left); |
|
w.set_xalign(0.0); |
|
w.set_valign(gtk::Align::Start); |
|
w.set_halign(gtk::Align::Fill); |
|
w.set_selectable(true); |
|
} |
|
|
|
fn build_room_msg_body(&self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); |
|
|
|
let msg_parts = self.create_msg_parts(&msg.body); |
|
|
|
if msg.mtype == RowType::Mention { |
|
for part in msg_parts.iter() { |
|
let highlights = msg.highlights.clone(); |
|
part.connect_property_cursor_position_notify(move |w| { |
|
if let Some(text) = w.get_text() { |
|
let attr = pango::AttrList::new(); |
|
for light in highlights.clone() { |
|
highlight_username(w.clone(), &attr, &light, text.to_string()); |
|
} |
|
w.set_attributes(Some(&attr)); |
|
} |
|
}); |
|
|
|
let highlights = msg.highlights.clone(); |
|
part.connect_property_selection_bound_notify(move |w| { |
|
if let Some(text) = w.get_text() { |
|
let attr = pango::AttrList::new(); |
|
for light in highlights.clone() { |
|
highlight_username(w.clone(), &attr, &light, text.to_string()); |
|
} |
|
w.set_attributes(Some(&attr)); |
|
} |
|
}); |
|
|
|
if let Some(text) = part.get_text() { |
|
let attr = pango::AttrList::new(); |
|
for light in msg.highlights.clone() { |
|
highlight_username(part.clone(), &attr, &light, text.to_string()); |
|
} |
|
part.set_attributes(Some(&attr)); |
|
} |
|
} |
|
} |
|
|
|
for part in msg_parts { |
|
self.connect_right_click_menu(msg, Some(&part)); |
|
bx.add(&part); |
|
} |
|
bx |
|
} |
|
|
|
fn create_msg_parts(&self, body: &str) -> Vec<gtk::Label> { |
|
let mut parts_labels: Vec<gtk::Label> = vec![]; |
|
|
|
for (k, group) in body.lines().group_by(kind_of_line).into_iter() { |
|
let mut v: Vec<&str> = if k == MsgPartType::Quote { |
|
group.map(|l| trim_start_quote(l)).collect() |
|
} else { |
|
group.collect() |
|
}; |
|
/* We need to remove the first and last empty line (if any) because quotes use /n/n */ |
|
if v.starts_with(&[""]) { |
|
v.drain(..1); |
|
} |
|
if v.ends_with(&[""]) { |
|
v.pop(); |
|
} |
|
let part = v.join("\n"); |
|
|
|
parts_labels.push(self.create_msg(part.as_str(), k)); |
|
} |
|
|
|
parts_labels |
|
} |
|
|
|
fn create_msg(&self, body: &str, k: MsgPartType) -> gtk::Label { |
|
let msg_part = gtk::Label::new(None); |
|
msg_part.set_markup(&markup_text(body)); |
|
self.set_label_styles(&msg_part); |
|
|
|
if k == MsgPartType::Quote { |
|
msg_part.get_style_context().add_class("quote"); |
|
} |
|
msg_part |
|
} |
|
|
|
fn build_room_msg_image(&mut self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Horizontal, 0); |
|
|
|
let img_path = match msg.thumb { |
|
Some(ref m) => m.clone(), |
|
None => msg.url.clone().unwrap_or_default(), |
|
}; |
|
let image = widgets::image::Image::new(&self.backend, self.server_url.clone(), &img_path) |
|
.size(Some(globals::MAX_IMAGE_SIZE)) |
|
.build(); |
|
|
|
image.widget.get_style_context().add_class("image-widget"); |
|
|
|
bx.pack_start(&image.widget, true, true, 0); |
|
bx.show_all(); |
|
self.image = Some(image.widget); |
|
self.connect_image(msg); |
|
|
|
bx |
|
} |
|
|
|
fn build_room_msg_sticker(&self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Horizontal, 0); |
|
let backend = self.backend.clone(); |
|
if let Some(url) = msg.url.as_ref() { |
|
let image = widgets::image::Image::new(&backend, self.server_url.clone(), url) |
|
.size(Some(globals::MAX_STICKER_SIZE)) |
|
.build(); |
|
image.widget.set_tooltip_text(Some(&msg.body[..])); |
|
|
|
bx.add(&image.widget); |
|
} |
|
|
|
bx |
|
} |
|
|
|
fn build_room_audio_player(&self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Horizontal, 6); |
|
let player = widgets::AudioPlayerWidget::new(); |
|
|
|
let url = msg.url.clone().unwrap_or_default(); |
|
let backend = self.backend.clone(); |
|
|
|
let (tx, rx): (Sender<String>, Receiver<String>) = channel(); |
|
backend |
|
.send(BKCommand::GetMediaUrl( |
|
self.server_url.clone(), |
|
url.clone(), |
|
tx, |
|
)) |
|
.unwrap(); |
|
|
|
gtk::timeout_add( |
|
50, |
|
clone!(player => move || { |
|
match rx.try_recv() { |
|
Err(TryRecvError::Empty) => gtk::Continue(true), |
|
Err(TryRecvError::Disconnected) => { |
|
let msg = i18n("Could not retrieve file URI"); |
|
/* FIXME: don't use APPOP! */ |
|
APPOP!(show_error, (msg)); |
|
gtk::Continue(true) |
|
}, |
|
Ok(uri) => { |
|
info!("AUDIO URI: {}", &uri); |
|
player.initialize_stream(&uri); |
|
gtk::Continue(false) |
|
} |
|
} |
|
}), |
|
); |
|
|
|
let download_btn = gtk::Button::new_from_icon_name( |
|
Some("document-save-symbolic"), |
|
gtk::IconSize::Button.into(), |
|
); |
|
download_btn.set_tooltip_text(Some(i18n("Save").as_str())); |
|
|
|
let data = glib::Variant::from(msg.id.as_str()); |
|
download_btn.set_action_target_value(Some(&data)); |
|
download_btn.set_action_name(Some("room_history.save_as")); |
|
|
|
bx.pack_start(&player.container, false, true, 0); |
|
bx.pack_start(&download_btn, false, false, 3); |
|
bx |
|
} |
|
|
|
fn build_room_msg_file(&self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Horizontal, 12); |
|
let btn_bx = gtk::Box::new(gtk::Orientation::Horizontal, 0); |
|
|
|
let name = msg.body.as_str(); |
|
let name_lbl = gtk::Label::new(Some(name)); |
|
name_lbl.set_tooltip_text(Some(name)); |
|
name_lbl.set_ellipsize(pango::EllipsizeMode::End); |
|
|
|
name_lbl.get_style_context().add_class("msg-highlighted"); |
|
|
|
let download_btn = gtk::Button::new_from_icon_name( |
|
Some("document-save-symbolic"), |
|
gtk::IconSize::Button.into(), |
|
); |
|
download_btn.set_tooltip_text(Some(i18n("Save").as_str())); |
|
|
|
let data = glib::Variant::from(msg.id.as_str()); |
|
download_btn.set_action_target_value(Some(&data)); |
|
download_btn.set_action_name(Some("room_history.save_as")); |
|
|
|
let open_btn = gtk::Button::new_from_icon_name( |
|
Some("document-open-symbolic"), |
|
gtk::IconSize::Button.into(), |
|
); |
|
open_btn.set_tooltip_text(Some(i18n("Open").as_str())); |
|
|
|
let data = glib::Variant::from(msg.id.as_str()); |
|
open_btn.set_action_target_value(Some(&data)); |
|
open_btn.set_action_name(Some("room_history.open_with")); |
|
|
|
btn_bx.pack_start(&open_btn, false, false, 0); |
|
btn_bx.pack_start(&download_btn, false, false, 0); |
|
btn_bx.get_style_context().add_class("linked"); |
|
|
|
bx.pack_start(&name_lbl, false, false, 0); |
|
bx.pack_start(&btn_bx, false, false, 0); |
|
bx |
|
} |
|
|
|
fn build_room_msg_date(&self, dt: &DateTime<Local>) -> gtk::Label { |
|
/* TODO: get system preference for 12h/24h */ |
|
let use_ampm = false; |
|
let format = if use_ampm { |
|
/* Use 12h time format (AM/PM) */ |
|
i18n("%l∶%M %p") |
|
} else { |
|
/* Use 24 time format */ |
|
i18n("%R") |
|
}; |
|
|
|
let d = dt.format(&format).to_string(); |
|
|
|
let date = gtk::Label::new(None); |
|
date.set_markup(&format!("<span alpha=\"60%\">{}</span>", d.trim())); |
|
date.set_line_wrap(true); |
|
date.set_justify(gtk::Justification::Right); |
|
date.set_valign(gtk::Align::Start); |
|
date.set_halign(gtk::Align::End); |
|
date.get_style_context().add_class("timestamp"); |
|
|
|
date |
|
} |
|
|
|
fn build_room_msg_info(&self, msg: &Message) -> gtk::Box { |
|
// info |
|
// +----------+------+ |
|
// | username | date | |
|
// +----------+------+ |
|
let info = gtk::Box::new(gtk::Orientation::Horizontal, 0); |
|
|
|
let username = self.build_room_msg_username(&msg.sender); |
|
let date = self.build_room_msg_date(&msg.date); |
|
|
|
self.username_event_box.add(&username); |
|
|
|
info.pack_start(&self.username_event_box, true, true, 0); |
|
info.pack_start(&date, false, false, 0); |
|
|
|
info |
|
} |
|
|
|
fn build_room_msg_emote(&self, msg: &Message) -> gtk::Box { |
|
let bx = gtk::Box::new(gtk::Orientation::Horizontal, 0); |
|
/* Use MXID till we have a alias */ |
|
let sname = msg |
|
.sender_name |
|
.clone() |
|
.unwrap_or(String::from(msg.sender.clone())); |
|
let msg_label = gtk::Label::new(None); |
|
let body: &str = &msg.body; |
|
let markup = markup_text(body); |
|
|
|
download_to_cache_username_emote( |
|
self.backend.clone(), |
|
self.server_url.clone(), |
|
&sname, |
|
&markup, |
|
msg_label.clone(), |
|
None, |
|
); |
|
|
|
self.connect_right_click_menu(msg, Some(&msg_label)); |
|
msg_label.set_markup(&format!("<b>{}</b> {}", sname, markup)); |
|
self.set_label_styles(&msg_label); |
|
|
|
bx.add(&msg_label); |
|
bx |
|
} |
|
|
|
fn connect_right_click_menu(&self, msg: &Message, label: Option<>k::Label>) -> Option<()> { |
|
let id = msg.id.clone(); |
|
let mtype = msg.mtype.clone(); |
|
let redactable = msg.redactable.clone(); |
|
let eventbox_weak = self.eventbox.downgrade(); |
|
let widget = if let Some(l) = label { |
|
l.upcast_ref::<gtk::Widget>() |
|
} else { |
|
self.eventbox.upcast_ref::<gtk::Widget>() |
|
}; |
|
|
|
let evbox = eventbox_weak.clone(); |
|
let i = id.clone(); |
|
widget.connect_button_press_event(move |w, e| { |
|
if e.get_button() == 3 { |
|
let eventbox = upgrade_weak!(evbox, gtk::Inhibit(false)); |
|
MessageMenu::new(i.as_str(), &mtype, &redactable, &eventbox, w); |
|
Inhibit(true) |
|
} else { |
|
Inhibit(false) |
|
} |
|
}); |
|
|
|
let widget_weak = widget.downgrade(); |
|
self.gesture.connect_pressed(move |_, _, _| { |
|
let eventbox = upgrade_weak!(eventbox_weak); |
|
let widget = upgrade_weak!(widget_weak); |
|
|
|
MessageMenu::new(&id, &mtype, &redactable, &eventbox, &widget); |
|
}); |
|
None |
|
} |
|
|
|
fn connect_image(&self, msg: &Message) -> Option<()> { |
|
let data = glib::Variant::from(msg.id.as_str()); |
|
self.row.set_action_name(Some("app.open-media-viewer")); |
|
self.row.set_action_target_value(Some(&data)); |
|
None |
|
} |
|
} |
|
|
|
fn highlight_username( |
|
label: gtk::Label, |
|
attr: &pango::AttrList, |
|
alias: &String, |
|
input: String, |
|
) -> Option<()> { |
|
fn contains((start, end): (i32, i32), item: i32) -> bool { |
|
if start <= end { |
|
start <= item && end > item |
|
} else { |
|
start <= item || end > item |
|
} |
|
} |
|
|
|
let input = input.to_lowercase(); |
|
let bounds = label.get_selection_bounds(); |
|
let context = label.get_style_context(); |
|
let fg = context.lookup_color("theme_selected_bg_color")?; |
|
let red = fg.red * 65535. + 0.5; |
|
let green = fg.green * 65535. + 0.5; |
|
let blue = fg.blue * 65535. + 0.5; |
|
let color = pango::Attribute::new_foreground(red as u16, green as u16, blue as u16)?; |
|
|
|
let mut input = input.clone(); |
|
let alias = &alias.to_lowercase(); |
|
let mut removed_char = 0; |
|
while input.contains(alias) { |
|
let pos = { |
|
let start = input.find(alias)? as i32; |
|
(start, start + alias.len() as i32) |
|
}; |
|
let mut color = color.clone(); |
|
let mark_start = removed_char as i32 + pos.0; |
|
let mark_end = removed_char as i32 + pos.1; |
|
let mut final_pos = Some((mark_start, mark_end)); |
|
/* exclude selected text */ |
|
if let Some((bounds_start, bounds_end)) = bounds { |
|
/* If the selection is within the alias */ |
|
if contains((mark_start, mark_end), bounds_start) |
|
&& contains((mark_start, mark_end), bounds_end) |
|
{ |
|
final_pos = Some((mark_start, bounds_start)); |
|
/* Add blue color after a selection */ |
|
let mut color = color.clone(); |
|
color.set_start_index(bounds_end as u32); |
|
color.set_end_index(mark_end as u32); |
|
attr.insert(color); |
|
} else { |
|
/* The alias starts inside a selection */ |
|
if contains(bounds?, mark_start) { |
|
final_pos = Some((bounds_end, final_pos?.1)); |
|
} |
|
/* The alias ends inside a selection */ |
|
if contains(bounds?, mark_end - 1) { |
|
final_pos = Some((final_pos?.0, bounds_start)); |
|
} |
|
} |
|
} |
|
|
|
if let Some((start, end)) = final_pos { |
|
color.set_start_index(start as u32); |
|
color.set_end_index(end as u32); |
|
attr.insert(color); |
|
} |
|
{ |
|
let end = pos.1 as usize; |
|
input.drain(0..end); |
|
} |
|
removed_char = removed_char + pos.1 as u32; |
|
} |
|
|
|
None |
|
} |
|
|
|
#[derive(PartialEq)] |
|
enum MsgPartType { |
|
Normal, |
|
Quote, |
|
} |
|
|
|
fn kind_of_line(line: &&str) -> MsgPartType { |
|
if line.trim_start().starts_with(">") { |
|
MsgPartType::Quote |
|
} else { |
|
MsgPartType::Normal |
|
} |
|
} |
|
|
|
fn trim_start_quote(line: &str) -> &str { |
|
line.trim_start().get(1..).unwrap_or(line).trim_start() |
|
}
|
|
|