mod builder;
mod expand;
mod message_box;
use super::*;
use crate::{ticket_tally::TicketTally, user::action::Pass_u8};
pub use builder::*;
use expand::MsgExpand;
pub use message_box::{QmsBox, QueryMsgFilter};
use mkentity::Entity;
use chrono::prelude::{DateTime, Local, Utc};
use std::collections::HashSet;
#[cfg(feature = "gui")]
use crossbeam_channel::Sender;
const MSG_ACTION_CHANNEL_SEND_ERR: &str = "Failed to send MsgAction via channel";
const NO_TOPICAL_ERR: &str = "Message has no topical asset";
pub const EXCERPT_TRUNCATE_LENGTH: usize = 30;
#[derive(Debug, Clone)]
pub enum MsgAction {
SendNew(QueryMsg),
Reply(QueryMsg),
ReplyAll(QueryMsg),
UpdateReadState(QueryMsg, ReadState),
BatchUpdateReadState( Vec<QueryMsg>, ReadState),
PilotAndMarkRead(QueryMsg),
Edit(QueryMsg),
Unsend(QueryMsg),
}
#[async_trait]
pub trait QmsChat: DynClone + fmt::Debug + Send + Sync {
async fn message_from_bson_id(
&mut self,
project: &Project,
id: &ObjectId,
viewer: Option<&Staff>,
) -> Result<QueryMsg, DatabaseError>;
async fn msg_synopsis_from_bson_id(
&mut self,
project: &Project,
id: &ObjectId,
) -> Result<MsgExcerpt, DatabaseError>;
async fn mixed_messages(
&mut self,
project: &Project,
topics: &BTreeSet<MsgTopic>,
qms_filter: &QueryMsgFilter,
viewer: Option<&Staff>,
) -> Result<QmsBox, DatabaseError>;
async fn uninstantiated_any_asset(
&mut self,
project: &Project,
topics: &BTreeSet<MsgTopic>,
qms_filter: &QueryMsgFilter,
viewer: &Staff,
) -> Result<Vec<QueryMsg>, DatabaseError>;
async fn handwritten_messages(
&mut self,
project: &Project,
qms_filter: QueryMsgFilter,
viewer: Option<&Staff>,
) -> Result<QmsBox, DatabaseError>;
async fn system_messages(
&mut self,
project: &Project,
topics: BTreeSet<MsgTopic>,
qms_filter: QueryMsgFilter,
viewer: Option<&Staff>,
) -> Result<QmsBox, DatabaseError>;
async fn sent_messages(
&mut self,
project: &Project,
topics: BTreeSet<MsgTopic>,
qms_filter: QueryMsgFilter,
viewer: Option<&Staff>,
) -> Result<QmsBox, DatabaseError>;
async fn send(&self, project: &Project, msg: QueryMsg) -> Result<(), ModificationError>;
async fn edit_message(
&self,
project: &Project,
existing: Option<&QueryMsg>,
updated: QueryMsg,
) -> Result<(), ModificationError>;
async fn mark_read(
&self,
project: &Project,
msg: &QueryMsg,
target: &ReadState,
viewer: &Staff,
) -> Result<(), ModificationError>;
async fn mark_instantiated(
&self,
project: &Project,
msg: &QueryMsg,
target: &InstantiationState,
viewer: &Staff,
) -> Result<(), ModificationError>;
async fn batch_mark_read(
&self,
project: &Project,
messages: &[QueryMsg],
target: &ReadState,
viewer: &Staff,
) -> Result<(), ModificationError>;
async fn unsend(&self, project: &Project, msg: &QueryMsg) -> Result<(), ModificationError>;
fn clear_cache(&mut self) {}
}
dyn_clone::clone_trait_object!(QmsChat);
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct QueryMsg {
#[serde(skip)]
pub ext: MsgExpand,
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
id: Option<ObjectId>,
#[serde(rename = "soup_format")]
topic: MsgTopic,
project: Option<String>,
#[serde(rename = "sender")]
pub sender_name: String,
subject: String,
content: String,
#[serde(rename = "topical_assets")]
pub topical_asset_bson_ids: Vec<ObjectId>,
#[serde(rename = "as_reply_to")]
pub as_reply_to_bson_ids: Option<Vec<ObjectId>>,
#[serde(rename = "recipients")]
pub recipient_names: Vec<String>,
hyperlinks: Option<Vec<HtmlLink>>,
#[serde(rename = "seen_by")]
pub seen_by_names: Vec<String>,
pub instantiated_for: Vec<String>,
#[serde(
rename = "datetime",
with = "bson::serde_helpers::chrono_datetime_as_bson_datetime"
)]
pub created_at: DateTime<Utc>,
#[cfg(feature = "ticket")]
#[serde(default, skip_serializing_if = "Option::is_none")]
subticket_number: Option<u8>,
}
impl BsonId for QueryMsg {
fn bson_id_as_ref(&self) -> Option<&ObjectId> {
self.id.as_ref()
}
fn bson_id(&self) -> AnyResult<&ObjectId> {
self.id
.as_ref()
.context("QueryMsg without BSON ObjectId")
}
}
impl QueryMsg {
pub fn empty() -> Self {
Self {
ext: MsgExpand::empty(),
created_at: Utc::now(),
..Default::default()
}
}
pub fn with_topic(mut self, topic: MsgTopic) -> Self {
self.topic = topic;
self
}
pub fn with_topical_asset_id(mut self, asset: Option<&AssetExcerpt>) -> AnyResult<Self> {
let id = asset
.context(NO_TOPICAL_ERR)?
.id
.context("Message without BSON ObjectId")?;
if !self.topical_asset_bson_ids.contains(&id) {
self.topical_asset_bson_ids.push(id.clone());
};
Ok(self)
}
pub fn with_topical_asset(mut self, asset: Option<AssetExcerpt>) -> Self {
self.ext.topical_asset_mut(asset);
self
}
#[cfg(feature = "ticket")]
pub fn subticket_number(mut self, subticket_number: Option<u8>) -> Self {
self.subticket_number = subticket_number;
self
}
pub fn empty_draft() -> Self {
Self::empty().with_mode(MediaMode::WriteCompose)
}
pub fn sent_by_current_user(user: Staff) -> Self {
let mut msg = Self::empty();
msg.ext.sender = user;
msg
}
pub fn content(&self) -> &String {
&self.content
}
pub fn topical(&self) -> AnyResult<&AssetExcerpt> {
Ok(self.ext.topical_asset().context(NO_TOPICAL_ERR)?)
}
pub fn topical_owned(self) -> AnyResult<AssetExcerpt> {
Ok(self.ext.topical_asset.context(NO_TOPICAL_ERR)?)
}
pub fn is_handwritten(&self) -> bool {
self.topic.is_handwritten()
}
pub fn inherit_from_reply(&mut self, msg: Self) {
if let Some(asset) = msg.ext.topical_asset() {
self.ext.topical_asset_mut(Some(asset.clone()));
};
self.ext.as_reply_to = Some(msg.into());
}
#[cfg(feature = "alert")]
fn alert_subject(&self, project: &Project) -> String {
if self.topic.is_handwritten() {
format!("{}/{}:", project, self.ext.sender.name_unwrap())
} else {
format!("{}: {}", project, self.subject)
}
}
#[cfg(feature = "alert")]
pub fn notify(&self, project: &Project) -> AnyResult<()> {
use mkutil::notify::notify_platform;
notify_platform(&self.alert_subject(project), &self.content, "Chat")
}
#[cfg(feature = "alert")]
pub fn pop_up(&self, project: &Project) -> bool {
use mkutil::dialog;
dialog::info_dialog(&self.alert_subject(project), &self.content)
}
}
#[cfg(feature = "gui")]
impl QueryMsg {
#[cfg(feature = "ticket")]
fn subticket_number_ui(&self, ui: &mut egui::Ui) {
if let Some(number) = &self.subticket_number {
ui.heading(format!("🎫 Subticket #{}", number));
};
}
fn content_ui(&self, ui: &mut egui::Ui) {
if !self.content.is_empty() {
ui.label(&self.content);
};
}
fn show_reply_button(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
if ui.button("⮪ Reply").clicked() {
tx.send(MsgAction::Reply(self.clone()))
.expect(MSG_ACTION_CHANNEL_SEND_ERR);
ui.close_menu();
};
}
fn show_reply_all_button(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
if ui.button("⮪ Reply All").clicked() {
tx.send(MsgAction::ReplyAll(self.clone()))
.expect(MSG_ACTION_CHANNEL_SEND_ERR);
ui.close_menu();
};
}
fn show_mark_unread_button(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
if self.ext.seen_by_self && ui.button("― Mark As Unread").clicked() {
tx.send(MsgAction::UpdateReadState(self.clone(), ReadState::Unread))
.expect(MSG_ACTION_CHANNEL_SEND_ERR);
ui.close_menu();
};
}
fn show_edit_button(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
if !MsgActionPerm::authorized(&self.ext.perm(), &MsgActionPerm::Edit) {
return;
}
if ui.button("✏ Edit").clicked() {
tx.send(MsgAction::Edit(MsgEditBuilder::new(self.clone()).finish()))
.expect(MSG_ACTION_CHANNEL_SEND_ERR);
ui.close_menu();
};
}
fn show_unsend_button(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
if !MsgActionPerm::authorized(&self.ext.perm(), &MsgActionPerm::Unsend) {
return;
};
if ui.button("🗙 Unsend").clicked() {
tx.send(MsgAction::Unsend(self.clone()))
.expect(MSG_ACTION_CHANNEL_SEND_ERR);
ui.close_menu();
};
}
fn show_unsend_menu(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
ui.menu_button("✉", |ui| {
self.show_unsend_button(ui, tx);
self.recipients_preview_ui(ui);
});
}
fn recipients_preview_ui(&self, ui: &mut egui::Ui) {
ui.label("👥 Recipients")
.on_hover_text(self.recipient_names.join(", "));
}
fn topical_asset_ui(&self, ui: &mut egui::Ui) {
if let Some(asset) = &self.ext.topical_asset {
asset.preview_name(ui);
};
}
fn mixed_msg_ui(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
match self.is_handwritten() {
true => {
self.handwritten_msg_ui(ui, tx);
}
false => {
self.system_msg_ui(ui, tx);
}
};
}
fn handwritten_msg_ui(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
self.ext.color_unread_frame(ui);
ui.group(|ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
let mut title = RichText::new("○");
if !self.ext.seen_by_self {
title = title.color(Color32::YELLOW);
};
ui.menu_button(title, |ui| {
self.show_reply_button(ui, tx);
self.show_reply_all_button(ui, tx);
self.show_mark_unread_button(ui, tx);
self.show_edit_button(ui, tx);
self.show_unsend_button(ui, tx);
self.recipients_preview_ui(ui);
});
self.topical_asset_ui(ui);
});
self.ext.reply_hint_ui(ui);
ui.strong(&self.sender_name);
self.content_ui(ui);
self.ext.seen_by_ui(ui);
self.ext.created_at_ui(ui);
self.pilot_if_double_clicked_in_rect(ui, tx);
})
});
}
fn system_msg_ui(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
self.ext.color_unread_frame(ui);
ui.group(|ui| {
ui.vertical(|ui| {
ui.horizontal_wrapped(|ui| {
let mut title = RichText::new("⚡");
if !self.ext.seen_by_self {
title = title.color(Color32::LIGHT_GREEN);
};
ui.menu_button(title, |ui| {
self.show_mark_unread_button(ui, tx);
self.show_unsend_button(ui, tx);
self.recipients_preview_ui(ui);
});
ui.label(&self.subject);
});
#[cfg(feature = "ticket")]
self.subticket_number_ui(ui);
self.content_ui(ui);
self.ext.seen_by_ui(ui);
self.ext.created_at_ui(ui);
self.pilot_if_double_clicked_in_rect(ui, tx);
});
});
}
fn sent_msg_ui(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
ui.group(|ui| {
ui.vertical(|ui| {
self.ext.sent_at_ui(ui);
if !self.is_handwritten() {
ui.horizontal_wrapped(|ui| {
self.show_unsend_menu(ui, tx);
ui.label(&self.subject);
});
#[cfg(feature = "ticket")]
self.subticket_number_ui(ui);
} else {
self.show_unsend_menu(ui, tx);
};
if self.topic.is_handwritten() {
self.topical_asset_ui(ui);
};
self.content_ui(ui);
self.ext.seen_by_ui(ui);
self.pilot_if_double_clicked_in_rect(ui, tx);
});
});
}
fn pilot_if_double_clicked_in_rect(&self, ui: &mut egui::Ui, tx: &Sender<MsgAction>) {
ui.ctx().input(|i| {
let pointer_state = &i.pointer;
if let Some(pos) = pointer_state.hover_pos() {
if pointer_state.button_double_clicked(egui::PointerButton::Primary)
&& ui.min_rect().contains(pos)
{
tx.send(MsgAction::PilotAndMarkRead(self.clone())).ok();
};
};
});
}
}
impl ReadWriteSuggest for QueryMsg {
fn write_suggest() -> Self {
Self::empty().with_mode(MediaMode::WriteSuggest)
}
fn with_mode(mut self, mode: MediaMode) -> Self {
self.mode_mut(mode);
self
}
fn mode(&self) -> &MediaMode {
&self.ext.mode
}
fn mode_mut(&mut self, mode: MediaMode) {
self.ext.mode = mode;
}
#[cfg(feature = "gui")]
fn write_compose_ui(&mut self, ui: &mut egui::Ui) {
self.ext.recipients_hint_ui(ui);
self.ext.reply_hint_mut_ui(ui);
ui.add(
egui::TextEdit::multiline(&mut self.content)
.desired_width(f32::INFINITY)
.hint_text("Type your message..."),
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "mongo", derive(Serialize, Deserialize))]
pub struct MsgExcerpt {
#[cfg_attr(
feature = "mongo",
serde(rename = "_id", skip_serializing_if = "Option::is_none")
)]
id: Option<ObjectId>,
#[cfg_attr(feature = "mongo", serde(rename = "sender"))]
sender_name: String,
pub content: String,
}
#[cfg(feature = "gui")]
impl MsgExcerpt {
fn reply_draft_ui(&self, ui: &mut egui::Ui) {
ui.label(format!(
"⮪ Replying to {}'s: {} [...]",
self.sender_name, self.content
));
}
fn reply_read_ui(&self, ui: &mut egui::Ui) {
ui.label(
RichText::new(format!(
"⮪ Reply to {}'s: {} [...]",
self.sender_name, self.content
))
.weak()
.small(),
);
}
}
impl From<QueryMsg> for MsgExcerpt {
fn from(mut msg: QueryMsg) -> Self {
msg.content.truncate(EXCERPT_TRUNCATE_LENGTH);
Self {
id: msg.id,
sender_name: msg.sender_name,
content: msg.content,
}
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
pub enum MsgActionPerm {
New = 2_u8.pow(0),
Reply = 2_u8.pow(1),
ReplyAll = 2_u8.pow(3),
MarkRead = 2_u8.pow(4),
MarkUnread = 2_u8.pow(5),
Edit = 2_u8.pow(6),
Unsend = 2_u8.pow(7),
}
impl MsgActionPerm {
fn default() -> impl Iterator<Item = u8> {
[
Self::New,
Self::Reply,
Self::ReplyAll,
Self::MarkRead,
Self::MarkUnread,
]
.into_iter()
.map(|a| a as u8)
}
pub fn default_sum() -> u8 {
Self::default().sum()
}
fn edit() -> u8 {
Self::default_sum() + Self::Edit as u8
}
pub fn edit_and_unsend() -> u8 {
Self::default_sum()
+ [Self::Edit, Self::Unsend]
.into_iter()
.map(|a| a as u8)
.sum::<u8>()
}
fn all() -> u8 {
Self::iter().map(|a| a as u8).sum()
}
pub fn per_role(viewer: &Staff, sender: &Staff) -> u8 {
match viewer.role {
ProductionRole::TechSupport => {
if viewer == sender {
Self::all()
} else {
Self::edit()
}
}
_ => {
if viewer == sender {
Self::edit_and_unsend()
} else {
Self::default_sum()
}
}
}
}
}
impl Pass_u8<MsgActionPerm> for MsgActionPerm {
fn authorized(perm: &u8, action: &MsgActionPerm) -> bool {
(perm & (*action as u8)) != 0
}
}
impl MsgAction {
pub fn recipients(&self) -> HashSet<Staff> {
match &self {
Self::Reply(inner) => HashSet::from([inner.ext.sender.clone()]),
Self::ReplyAll(inner) => {
let mut r = inner.ext.recipients_owned();
r.insert(inner.ext.sender.clone());
r
}
Self::Edit(inner)
| Self::SendNew(inner)
| Self::Unsend(inner)
| Self::UpdateReadState(inner, _)
| Self::PilotAndMarkRead(inner)
=> inner.ext.recipients_owned(),
Self::BatchUpdateReadState(_, _) => {
unimplemented!()
}
}
}
}
#[cfg(feature = "gui")]
fn no_topical_asset_warning(ui: &mut egui::Ui) {
ui.colored_label(Color32::LIGHT_RED, "▪ None");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn msg_action_perm() {
let perm = MsgActionPerm::default_sum();
assert_eq!(MsgActionPerm::authorized(&perm, &MsgActionPerm::New), true);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::Reply),
true
);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::ReplyAll),
true
);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::MarkRead),
true
);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::MarkUnread),
true
);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::Edit),
false
);
assert_eq!(
MsgActionPerm::authorized(&perm, &MsgActionPerm::Unsend),
false
);
}
}