mod model;
mod view;
pub mod prelude {
pub use super::ArticleContent;
}
use model::ArticleContentModelData;
use url::Url;
use view::ArticleContentViewData;
use crate::prelude::*;
use std::sync::Arc;
use news_flash::models::{ArticleID, Enclosure, Thumbnail};
use tokio::sync::mpsc::UnboundedSender;
pub struct ArticleContent {
config: Arc<Config>,
view_data: ArticleContentViewData,
model_data: ArticleContentModelData,
message_sender: UnboundedSender<Message>,
is_focused: bool,
is_distraction_free: bool,
}
impl ArticleContent {
pub fn new(
config: Arc<Config>,
news_flash_utils: Arc<NewsFlashUtils>,
message_sender: UnboundedSender<Message>,
) -> Self {
Self {
config,
view_data: ArticleContentViewData::default(),
model_data: ArticleContentModelData::new(news_flash_utils, message_sender.clone()),
message_sender,
is_focused: false,
is_distraction_free: false,
}
}
async fn on_article_selected(&mut self, article_id: &ArticleID) -> color_eyre::Result<()> {
self.model_data
.on_article_selected(article_id, self.is_focused)
.await?;
self.view_data.clear_image();
self.view_data.scroll_to_top();
self.view_data.update(&self.model_data, self.config.clone());
self.update_thumbnail_fetching_state()?;
Ok(())
}
fn prepare_thumbnail(&mut self, thumbnail: &Thumbnail) -> color_eyre::Result<()> {
let image = self
.model_data
.prepare_thumbnail(thumbnail, self.view_data.picker())?;
self.view_data.set_image(image);
Ok(())
}
fn scrape_article(&mut self) -> color_eyre::Result<()> {
self.model_data.scrape_article()?;
Ok(())
}
fn update_thumbnail_fetching_state(&mut self) -> color_eyre::Result<bool> {
self.view_data.tick_throbber();
if self.model_data.update_should_fetch_thumbnail(&self.config) {
self.fetch_thumbnail()?;
}
Ok(*self.model_data.thumbnail_fetch_running())
}
fn fetch_thumbnail(&mut self) -> color_eyre::Result<()> {
if self.view_data.image().is_none() {
self.model_data.start_fetch_thumbnail()?;
self.view_data.reset_thumbnail_throbber();
}
Ok(())
}
fn share_url(&self, target_str: &str, title: &str, url: &Url) -> color_eyre::Result<()> {
let Some(target) = self
.config
.share_targets
.iter()
.find(|target| target.as_ref() == target_str)
else {
tooltip(
&self.message_sender,
&*format!("unknown share target {target_str}"),
TooltipFlavor::Error,
)?;
return Ok(());
};
match target.share(title, url) {
Ok(()) => tooltip(
&self.message_sender,
&*format!("shared with {}", target),
TooltipFlavor::Info,
)?,
Err(error) => tooltip(
&self.message_sender,
&*format!("unable to shared with {}: {}", target, error),
TooltipFlavor::Error,
)?,
}
Ok(())
}
fn share_article(&self, target_str: &str) -> color_eyre::Result<()> {
let Some(article) = self.model_data.article() else {
tooltip(
&self.message_sender,
"no article loaded",
TooltipFlavor::Warning,
)?;
return Ok(());
};
let Some(url) = article.url.as_ref() else {
tooltip(
&self.message_sender,
"article has no URL",
TooltipFlavor::Warning,
)?;
return Ok(());
};
let title: &str = article.title.as_deref().unwrap_or("no title");
self.share_url(target_str, title, url.as_ref())?;
Ok(())
}
async fn open_enclosure(
&self,
enclosure_type: Option<EnclosureType>,
) -> color_eyre::Result<()> {
let Some(enclosures) = self.model_data.enclosures() else {
tooltip(
&self.message_sender,
"no enclosures available",
TooltipFlavor::Warning,
)?;
return Ok(());
};
let enclosures_matching_type = enclosures
.iter()
.filter(|enclosure| {
enclosure_type
.map(|enclosure_type| enclosure_type == (*enclosure).into())
.unwrap_or(true)
})
.collect::<Vec<&Enclosure>>();
let matching_enclosure = enclosures_matching_type
.iter()
.find(|enclosure| enclosure.is_default)
.or_else(|| enclosures_matching_type.first());
let Some(matching_enclosure) = matching_enclosure else {
tooltip(
&self.message_sender,
"no matching enclosure found",
TooltipFlavor::Warning,
)?;
return Ok(());
};
match self
.model_data
.open_enclosure(&self.config, matching_enclosure)
.await
{
Ok(cmd) => tooltip(
&self.message_sender,
&*format!("openend enclosure with {cmd}"),
TooltipFlavor::Info,
)?,
Err(err) => {
return tooltip(
&self.message_sender,
err.to_string().as_str(),
TooltipFlavor::Error,
);
}
}
Ok(())
}
fn open_hint(&self, command: &Command) -> color_eyre::Result<()> {
let hint = match command {
Command::ContentFollowHint(hint) => &hint.to_owned(),
Command::ContentShareHint(_, hint) => &hint.to_owned(),
_ => unreachable!(),
};
let Some(url) = self.view_data.url_for_hint().get(&hint.to_uppercase()) else {
tooltip(
&self.message_sender,
"unknown hint number",
TooltipFlavor::Error,
)?;
return Ok(());
};
let url_result = Url::parse(url);
let Ok(url) = url_result else {
tooltip(
&self.message_sender,
&*format!("cannot open invalid URL: {}", url_result.unwrap_err()),
TooltipFlavor::Error,
)?;
return Ok(());
};
let title = self
.model_data
.article()
.as_ref()
.and_then(|article| article.title.as_ref())
.map(|title| format!("Link from {title}"))
.unwrap_or("".to_owned());
match command {
Command::ContentFollowHint(..) => {
if let Err(err) = webbrowser::open(url.as_ref()) {
tooltip(
&self.message_sender,
&*format!("unable to open webbrowser: {err}"),
TooltipFlavor::Error,
)?;
}
}
Command::ContentShareHint(target_str, ..) => {
self.share_url(target_str, &title, &url)?;
}
_ => {}
}
Ok(())
}
}
impl crate::messages::MessageReceiver for ArticleContent {
async fn process_command(&mut self, message: &Message) -> color_eyre::Result<()> {
let mut view_needs_update = false;
if let Message::Command(command) = message {
use Command as C;
view_needs_update = true;
let mut handle_command = false;
let Some(command) = (match command {
C::In(Panel::ArticleContent, command) => {
handle_command = true;
Some(*command.to_owned())
}
C::In(..) => None,
command => {
handle_command = self.is_focused;
Some(command.to_owned())
}
}) else {
return Ok(());
};
match command {
C::NavigateDown if handle_command => {
self.view_data.scroll_down();
}
C::NavigateUp if handle_command => {
self.view_data.scroll_up();
}
C::NavigatePageUp if handle_command => {
self.view_data
.scroll_page_up(self.config.input_config.scroll_amount as u16);
}
C::NavigatePageDown if handle_command => {
self.view_data
.scroll_page_down(self.config.input_config.scroll_amount as u16);
}
C::NavigateFirst if handle_command => {
self.view_data.scroll_to_top();
}
C::NavigateLast if handle_command => {
self.view_data.scroll_to_bottom();
}
C::ArticleCurrentScrape => {
self.scrape_article()?;
}
C::ArticleShare(target) => {
self.share_article(&target)?;
}
C::Refresh => {
view_needs_update = true;
}
C::ArticleOpenEnclosure(enclosure_type) => {
self.open_enclosure(enclosure_type).await?;
}
hint_command @ (C::ContentFollowHint(..) | C::ContentShareHint(..)) => {
self.open_hint(&hint_command)?;
}
set_read_command @ C::ActionSetRead(_) if handle_command => {
self.message_sender.send(Message::Command(C::In(
Panel::ArticleList,
Box::new(set_read_command),
)))?;
}
_ => {
view_needs_update = false;
}
}
}
if let Message::Event(event) = message {
use Event::*;
match event {
ArticleSelected(article_id) => {
self.on_article_selected(article_id).await?;
view_needs_update = true;
}
FatArticleSelected(article) => {
self.model_data
.on_article_selected(article, self.is_focused)
.await?;
if self.is_focused && self.config.auto_scrape {
self.scrape_article()?;
}
view_needs_update = true;
}
AsyncArticleThumbnailFetchFinished(thumbnail) => {
self.model_data
.on_thumbnail_fetch_finished(thumbnail.as_ref());
match thumbnail {
Some(thumbnail) => {
self.prepare_thumbnail(thumbnail)?;
}
None => {
log::debug!("fetching thumbnail not successful");
self.view_data.clear_image();
self.model_data.on_thumbnail_fetch_failed();
}
}
view_needs_update = true;
}
AsyncOperationFailed(err, reason) => {
if let Event::AsyncArticleThumbnailFetch = *reason.as_ref() {
log::debug!("fetching thumbnail not successful: {err}");
self.view_data.clear_image();
self.model_data.on_thumbnail_fetch_failed();
view_needs_update = true;
}
}
AsyncArticleFatFetchFinished(fat_article) => {
self.model_data.set_fat_article(fat_article.clone());
self.model_data
.get_or_create_markdown_content(&self.config)?;
view_needs_update = true;
}
ApplicationStateChanged(state) => {
self.is_focused = *state == AppState::ArticleContent
|| *state == AppState::ArticleContentDistractionFree;
self.is_distraction_free = *state == AppState::ArticleContentDistractionFree;
if self.is_focused && self.config.auto_scrape {
self.scrape_article()?;
}
view_needs_update = true;
}
Tick => {
view_needs_update = self.update_thumbnail_fetching_state()?;
}
MouseScrollDown(Panel::ArticleContent) => {
self.view_data.scroll_down();
}
MouseScrollUp(Panel::ArticleContent) => {
self.view_data.scroll_up();
}
event if event.caused_model_update() => {
view_needs_update = true;
}
_ => {}
}
}
if view_needs_update {
self.view_data.update(&self.model_data, self.config.clone());
self.message_sender
.send(Message::Command(Command::Redraw))?;
}
Ok(())
}
}