use std::collections::HashSet; use graphql_client::GraphQLQuery; use log::{error, info}; use seed::{prelude::*, *}; use thiserror::Error; use crate::{ api, api::urls, consts::SEARCH_RESULTS_PER_PAGE, graphql, graphql::{front_page_query::*, send_graphql, show_thread_query::*}, }; /// Used to fake the unread string while in development pub fn unread_query() -> &'static str { let host = seed::window() .location() .host() .expect("failed to get host"); if host.starts_with("6758.") { return "tag:letterbox"; } "is:unread" } // `init` describes what should happen when your app started. pub fn init(url: Url, orders: &mut impl Orders) -> Model { if url.hash().is_none() { orders.request_url(urls::search(unread_query(), 0)); } else { orders.notify(subs::UrlRequested::new(url)); }; orders.stream(streams::window_event(Ev::Resize, |_| Msg::OnResize)); // TODO(wathiede): only do this while viewing the index? Or maybe add a new message that force // 'notmuch new' on the server periodically? orders.stream(streams::interval(30_000, || Msg::RefreshStart)); orders.subscribe(on_url_changed); Model { context: Context::None, query: "".to_string(), refreshing_state: RefreshingState::None, tags: None, } } fn on_url_changed(uc: subs::UrlChanged) -> Msg { let mut url = uc.0; info!( "url changed '{}', history {}", url, history().length().unwrap_or(0) ); let hpp = url.remaining_hash_path_parts(); match hpp.as_slice() { ["t", tid] => Msg::ShowThreadRequest { thread_id: tid.to_string(), }, ["s", query] => { let query = Url::decode_uri_component(query).unwrap_or("".to_string()); Msg::FrontPageRequest { query, after: None, before: None, first: None, last: None, } } ["s", query, page] => { let query = Url::decode_uri_component(query).unwrap_or("".to_string()); let page = page[1..].parse().unwrap_or(0); Msg::FrontPageRequest { query, after: Some(page.to_string()), before: None, first: None, last: None, } } p => { if !p.is_empty() { info!("Unhandled path '{p:?}'"); } Msg::FrontPageRequest { query: "".to_string(), after: None, before: None, first: None, last: None, } } } } // `update` describes how to handle each `Msg`. pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { match msg { Msg::Noop => {} Msg::RefreshStart => { model.refreshing_state = RefreshingState::Loading; orders.perform_cmd(async move { Msg::RefreshDone(api::refresh_request().await.err()) }); } Msg::RefreshDone(err) => { model.refreshing_state = if let Some(err) = err { RefreshingState::Error(format!("{:?}", err)) } else { RefreshingState::None }; orders.perform_cmd(async move { Msg::Reload }); } Msg::Reload => { orders.perform_cmd(async move { on_url_changed(subs::UrlChanged(Url::current())) }); } Msg::OnResize => (), Msg::NextPage => { match &model.context { Context::SearchResult { query, pager, .. } => { let query = query.to_string(); let after = pager.end_cursor.clone(); orders.perform_cmd(async move { Msg::FrontPageRequest { query, after, before: None, first: Some(SEARCH_RESULTS_PER_PAGE as i64), last: None, } }); } Context::ThreadResult { .. } => (), // do nothing (yet?) Context::None => (), // do nothing (yet?) }; } Msg::PreviousPage => { match &model.context { Context::SearchResult { query, pager, .. } => { let query = query.to_string(); let before = pager.start_cursor.clone(); orders.perform_cmd(async move { Msg::FrontPageRequest { query, after: None, before, first: None, last: Some(SEARCH_RESULTS_PER_PAGE as i64), } }); } Context::ThreadResult { .. } => (), // do nothing (yet?) Context::None => (), // do nothing (yet?) }; } Msg::UpdateQuery(query) => model.query = query, Msg::SearchQuery(query) => { orders.request_url(urls::search(&query, 0)); } Msg::SetUnread(query, unread) => { let search_url = urls::search(&model.query, 0).to_string(); orders.skip().perform_cmd(async move { let res: Result< graphql_client::Response, gloo_net::Error, > = send_graphql(graphql::MarkReadMutation::build_query( graphql::mark_read_mutation::Variables { query: query.clone(), unread, }, )) .await; if let Err(e) = res { error!("Failed to set read for {query} to {unread}: {e}"); } seed::window() .location() .set_href(&search_url) .expect("failed to change location"); Msg::Noop }); } Msg::AddTag(query, tag) => { let search_url = urls::search(&model.query, 0).to_string(); orders.skip().perform_cmd(async move { let res: Result< graphql_client::Response, gloo_net::Error, > = send_graphql(graphql::AddTagMutation::build_query( graphql::add_tag_mutation::Variables { query: query.clone(), tag: tag.clone(), }, )) .await; if let Err(e) = res { error!("Failed to add tag {tag} to {query}: {e}"); } seed::window() .location() .set_href(&search_url) .expect("failed to change location"); Msg::Noop }); } Msg::RemoveTag(query, tag) => { let search_url = urls::search(&model.query, 0).to_string(); orders.skip().perform_cmd(async move { let res: Result< graphql_client::Response, gloo_net::Error, > = send_graphql(graphql::RemoveTagMutation::build_query( graphql::remove_tag_mutation::Variables { query: query.clone(), tag: tag.clone(), }, )) .await; if let Err(e) = res { error!("Failed to remove tag {tag} to {query}: {e}"); } // TODO: reconsider this behavior seed::window() .location() .set_href(&search_url) .expect("failed to change location"); Msg::Noop }); } Msg::FrontPageRequest { query, after, before, first, last, } => { model.query = query.clone(); orders.skip().perform_cmd(async move { Msg::FrontPageResult( send_graphql(graphql::FrontPageQuery::build_query( graphql::front_page_query::Variables { query, after, before, first, last, }, )) .await, ) }); } Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"), Msg::FrontPageResult(Ok(graphql_client::Response { data: None, errors: None, .. })) => { error!("FrontPageResult no data or errors, should not happen"); } Msg::FrontPageResult(Ok(graphql_client::Response { data: None, errors: Some(e), .. })) => { error!("FrontPageResult error: {e:?}"); } Msg::FrontPageResult(Ok(graphql_client::Response { data: Some(data), .. })) => { model.tags = Some( data.tags .into_iter() .map(|t| Tag { name: t.name, bg_color: t.bg_color, fg_color: t.fg_color, unread: t.unread, }) .collect(), ); model.context = Context::SearchResult { query: model.query.clone(), results: data.search.nodes, count: data.count as usize, pager: data.search.page_info, selected_threads: HashSet::new(), }; } Msg::ShowThreadRequest { thread_id } => { orders.skip().perform_cmd(async move { Msg::ShowThreadResult( send_graphql(graphql::ShowThreadQuery::build_query( graphql::show_thread_query::Variables { thread_id }, )) .await, ) }); } Msg::ShowThreadResult(Ok(graphql_client::Response { data: Some(data), .. })) => { model.tags = Some( data.tags .into_iter() .map(|t| Tag { name: t.name, bg_color: t.bg_color, fg_color: t.fg_color, unread: t.unread, }) .collect(), ); let mut open_messages: HashSet<_> = data .thread .messages .iter() .filter(|msg| msg.tags.iter().any(|t| t == "unread")) .map(|msg| msg.id.clone()) .collect(); if open_messages.is_empty() { open_messages = data .thread .messages .iter() .map(|msg| msg.id.clone()) .collect(); } model.context = Context::ThreadResult { thread: data.thread, open_messages, }; } Msg::ShowThreadResult(bad) => { error!("show_thread_query error: {bad:#?}"); } Msg::SelectionSetNone => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { *selected_threads = HashSet::new(); } } Msg::SelectionSetAll => { if let Context::SearchResult { results, selected_threads, .. } = &mut model.context { *selected_threads = results.iter().map(|node| node.thread.clone()).collect(); } } Msg::SelectionAddTag(tag) => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { let threads = selected_threads .iter() .map(|tid| format!("thread:{tid}")) .collect::>() .join(" "); orders .skip() .perform_cmd(async move { Msg::AddTag(threads, tag) }); } } Msg::SelectionRemoveTag(tag) => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { let threads = selected_threads .iter() .map(|tid| format!("thread:{tid}")) .collect::>() .join(" "); orders .skip() .perform_cmd(async move { Msg::RemoveTag(threads, tag) }); } } Msg::SelectionMarkAsRead => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { let threads = selected_threads .iter() .map(|tid| format!("thread:{tid}")) .collect::>() .join(" "); orders .skip() .perform_cmd(async move { Msg::SetUnread(threads, false) }); } } Msg::SelectionMarkAsUnread => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { let threads = selected_threads .iter() .map(|tid| format!("thread:{tid}")) .collect::>() .join(" "); orders .skip() .perform_cmd(async move { Msg::SetUnread(threads, true) }); } } Msg::SelectionAddThread(tid) => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { selected_threads.insert(tid); } } Msg::SelectionRemoveThread(tid) => { if let Context::SearchResult { selected_threads, .. } = &mut model.context { selected_threads.remove(&tid); } } Msg::MessageCollapse(id) => { if let Context::ThreadResult { open_messages, .. } = &mut model.context { open_messages.remove(&id); } } Msg::MessageExpand(id) => { if let Context::ThreadResult { open_messages, .. } = &mut model.context { open_messages.insert(id); } } Msg::MultiMsg(msgs) => msgs.into_iter().for_each(|msg| update(msg, model, orders)), } } // `Model` describes our app state. pub struct Model { pub query: String, pub context: Context, pub refreshing_state: RefreshingState, pub tags: Option>, } #[derive(Error, Debug)] #[allow(dead_code)] // Remove once the UI is showing errors pub enum UIError { #[error("No error, this should never be presented to user")] NoError, #[error("failed to fetch {0}: {1:?}")] FetchError(&'static str, gloo_net::Error), #[error("{0} error decoding: {1:?}")] FetchDecodeError(&'static str, Vec), #[error("no data or errors for {0}")] NoData(&'static str), } pub enum Context { None, SearchResult { query: String, results: Vec, count: usize, pager: FrontPageQuerySearchPageInfo, selected_threads: HashSet, }, ThreadResult { thread: ShowThreadQueryThread, open_messages: HashSet, }, } pub struct Tag { pub name: String, pub bg_color: String, pub fg_color: String, pub unread: i64, } #[derive(Debug, PartialEq)] pub enum RefreshingState { None, Loading, Error(String), } // `Msg` describes the different events you can modify state with. pub enum Msg { Noop, // Tell the client to refresh its state Reload, // Window has changed size OnResize, // Tell the server to update state RefreshStart, RefreshDone(Option), NextPage, PreviousPage, UpdateQuery(String), SearchQuery(String), SetUnread(String, bool), AddTag(String, String), RemoveTag(String, String), FrontPageRequest { query: String, after: Option, before: Option, first: Option, last: Option, }, FrontPageResult( Result, gloo_net::Error>, ), ShowThreadRequest { thread_id: String, }, ShowThreadResult( Result, gloo_net::Error>, ), SelectionSetNone, SelectionSetAll, SelectionAddTag(String), SelectionRemoveTag(String), SelectionMarkAsRead, SelectionMarkAsUnread, SelectionAddThread(String), SelectionRemoveThread(String), MessageCollapse(String), MessageExpand(String), MultiMsg(Vec), }