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 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}