tuisky/components/views/
feed.rs

1use super::types::{Action, Data, Transition, View};
2use super::utils::{counts, profile_name, profile_name_as_str};
3use super::ViewComponent;
4use crate::backend::types::FeedSourceInfo;
5use crate::backend::{Watch, Watcher};
6use bsky_sdk::api::app::bsky::feed::defs::{
7    FeedViewPost, FeedViewPostReasonRefs, PostViewEmbedRefs, ReplyRefParentRefs,
8};
9use bsky_sdk::api::app::bsky::feed::post;
10use bsky_sdk::api::types::{TryFromUnknown, Union};
11use chrono::Local;
12use color_eyre::Result;
13use ratatui::layout::{Constraint, Layout, Rect};
14use ratatui::style::{Color, Style, Stylize};
15use ratatui::text::{Line, Span, Text};
16use ratatui::widgets::{Block, Borders, List, ListState, Padding, Paragraph};
17use ratatui::Frame;
18use std::sync::Arc;
19use textwrap::Options;
20use tokio::sync::mpsc::UnboundedSender;
21use tokio::sync::oneshot;
22
23pub struct FeedViewComponent {
24    items: Vec<FeedViewPost>,
25    state: ListState,
26    action_tx: UnboundedSender<Action>,
27    feed_info: FeedSourceInfo,
28    watcher: Box<dyn Watch<Output = Vec<FeedViewPost>>>,
29    quit: Option<oneshot::Sender<()>>,
30}
31
32impl FeedViewComponent {
33    pub fn new(
34        action_tx: UnboundedSender<Action>,
35        watcher: Arc<Watcher>,
36        feed_info: FeedSourceInfo,
37    ) -> Self {
38        let watcher = Box::new(watcher.feed(feed_info.clone()));
39        Self {
40            items: Vec::new(),
41            state: ListState::default(),
42            action_tx,
43            feed_info,
44            watcher,
45            quit: None,
46        }
47    }
48    fn lines(feed_view_post: &FeedViewPost, area: Rect) -> Option<Vec<Line>> {
49        let Ok(record) = post::Record::try_from_unknown(feed_view_post.post.record.clone()) else {
50            return None;
51        };
52        let mut lines = Vec::new();
53        {
54            let mut spans = [
55                vec![
56                    Span::from(
57                        feed_view_post
58                            .post
59                            .indexed_at
60                            .as_ref()
61                            .with_timezone(&Local)
62                            .format("%Y-%m-%d %H:%M:%S %z")
63                            .to_string(),
64                    )
65                    .green(),
66                    Span::from(": "),
67                ],
68                profile_name(&feed_view_post.post.author),
69            ]
70            .concat();
71            if let Some(labels) = feed_view_post
72                .post
73                .author
74                .labels
75                .as_ref()
76                .filter(|v| !v.is_empty())
77            {
78                spans.push(Span::from(" "));
79                spans.push(format!("[{} labels]", labels.len()).magenta());
80            }
81            lines.push(Line::from(spans));
82        }
83        if let Some(Union::Refs(FeedViewPostReasonRefs::ReasonRepost(repost))) =
84            &feed_view_post.reason
85        {
86            lines.push(
87                Line::from(format!("  Reposted by {}", profile_name_as_str(&repost.by))).blue(),
88            );
89        }
90        if let Some(reply) = &feed_view_post.reply {
91            if let Union::Refs(ReplyRefParentRefs::PostView(post_view)) = &reply.parent {
92                lines.push(Line::from(
93                    [
94                        vec![Span::from("  Reply to ").blue()],
95                        profile_name(&post_view.author),
96                    ]
97                    .concat(),
98                ));
99            }
100        }
101        lines.extend(
102            textwrap::wrap(
103                &record.text,
104                Options::new(usize::from(area.width) - 2)
105                    .initial_indent("  ")
106                    .subsequent_indent("  "),
107            )
108            .iter()
109            .map(|s| Line::from(s.to_string())),
110        );
111        if let Some(embed) = &feed_view_post.post.embed {
112            let content = match embed {
113                Union::Refs(PostViewEmbedRefs::AppBskyEmbedImagesView(images)) => {
114                    format!("{} images", images.images.len())
115                }
116                Union::Refs(PostViewEmbedRefs::AppBskyEmbedExternalView(_)) => {
117                    String::from("external")
118                }
119                Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordView(_)) => String::from("record"),
120                Union::Refs(PostViewEmbedRefs::AppBskyEmbedRecordWithMediaView(_)) => {
121                    String::from("recordWithMedia")
122                }
123                _ => String::from("unknown"),
124            };
125            lines.push(Line::from(format!("  Embedded {content}")).yellow());
126        }
127        lines.push(Line::from(
128            [vec![Span::from("  ")], counts(&feed_view_post.post, 5)].concat(),
129        ));
130        Some(lines)
131    }
132}
133
134impl ViewComponent for FeedViewComponent {
135    fn view(&self) -> View {
136        View::Feed(Box::new(self.feed_info.clone()))
137    }
138    fn activate(&mut self) -> Result<()> {
139        let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe());
140        let (quit_tx, mut quit_rx) = oneshot::channel();
141        self.quit = Some(quit_tx);
142        tokio::spawn(async move {
143            loop {
144                tokio::select! {
145                    changed = rx.changed() => {
146                        match changed {
147                            Ok(()) => {
148                                if let Err(e) = tx.send(Action::Update(Box::new(Data::Feed(
149                                    rx.borrow_and_update().clone(),
150                                )))) {
151                                    log::error!("failed to send update action: {e}");
152                                }
153                            }
154                            Err(e) => {
155                                log::warn!("changed channel error: {e}");
156                                break;
157                            }
158                        }
159                    }
160                    _ = &mut quit_rx => {
161                        break;
162                    }
163                }
164            }
165            log::debug!("subscription finished");
166        });
167        Ok(())
168    }
169    fn deactivate(&mut self) -> Result<()> {
170        if let Some(tx) = self.quit.take() {
171            if tx.send(()).is_err() {
172                log::error!("failed to send quit signal");
173            }
174        }
175        self.watcher.unsubscribe();
176        Ok(())
177    }
178    fn update(&mut self, action: Action) -> Result<Option<Action>> {
179        match action {
180            Action::NextItem if !self.items.is_empty() => {
181                self.state.select(Some(
182                    self.state
183                        .selected()
184                        .map(|s| (s + 1).min(self.items.len() - 1))
185                        .unwrap_or_default(),
186                ));
187                return Ok(Some(Action::Render));
188            }
189            Action::PrevItem if !self.items.is_empty() => {
190                self.state.select(Some(
191                    self.state
192                        .selected()
193                        .map(|s| s.max(1) - 1)
194                        .unwrap_or_default(),
195                ));
196                return Ok(Some(Action::Render));
197            }
198            Action::Enter => {
199                if let Some(feed_view_post) = self.state.selected().and_then(|i| self.items.get(i))
200                {
201                    return Ok(Some(Action::Transition(Transition::Push(Box::new(
202                        View::Post(Box::new((
203                            feed_view_post.post.clone(),
204                            feed_view_post
205                                .reply
206                                .as_ref()
207                                .and_then(|reply| match &reply.parent {
208                                    Union::Refs(ReplyRefParentRefs::PostView(post_view)) => {
209                                        Some(post_view.as_ref().clone())
210                                    }
211                                    _ => None,
212                                }),
213                        ))),
214                    )))));
215                }
216            }
217            Action::Back => return Ok(Some(Action::Transition(Transition::Pop))),
218            Action::Refresh => {
219                self.watcher.refresh();
220            }
221            Action::Update(data) => {
222                let Data::Feed(feed) = data.as_ref() else {
223                    return Ok(None);
224                };
225                log::debug!("update feed view: {}", feed.len());
226                // TODO: update state.selected
227                let select = if let Some(cid) = self
228                    .state
229                    .selected()
230                    .and_then(|i| self.items.get(i))
231                    .map(|feed_view_post| feed_view_post.post.cid.as_ref())
232                {
233                    feed.iter()
234                        .position(|feed_view_post| feed_view_post.post.cid.as_ref() == cid)
235                } else {
236                    None
237                };
238                self.items.clone_from(feed);
239                self.state.select(select);
240                return Ok(Some(Action::Render));
241            }
242            _ => {}
243        }
244        Ok(None)
245    }
246    fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
247        let header = Paragraph::new(match &self.feed_info {
248            FeedSourceInfo::Feed(generator_view) => Line::from(vec![
249                Span::from(generator_view.display_name.clone()).bold(),
250                Span::from(" "),
251                Span::from(format!(
252                    "by {}",
253                    profile_name_as_str(&generator_view.creator)
254                ))
255                .gray(),
256            ]),
257            FeedSourceInfo::List(list_view) => Line::from(vec![
258                Span::from(list_view.name.clone()).bold(),
259                Span::from(" "),
260                Span::from(format!("by {}", profile_name_as_str(&list_view.creator))).gray(),
261            ]),
262            FeedSourceInfo::Timeline(_) => Line::from("Following").bold(),
263        })
264        .bold()
265        .block(
266            Block::default()
267                .borders(Borders::BOTTOM)
268                .border_style(Color::Gray)
269                .padding(Padding::horizontal(1)),
270        );
271        let mut items = Vec::new();
272        for feed_view_post in &self.items {
273            if let Some(lines) = Self::lines(feed_view_post, area) {
274                items.push(Text::from(lines));
275            }
276        }
277
278        let layout =
279            Layout::vertical([Constraint::Length(2), Constraint::Percentage(100)]).split(area);
280        f.render_widget(header, layout[0]);
281        f.render_stateful_widget(
282            List::new(items)
283                .highlight_style(Style::default().reset().reversed())
284                .block(Block::default().padding(Padding::horizontal(1))),
285            layout[1],
286            &mut self.state,
287        );
288        Ok(())
289    }
290}