diff --git a/src/server/xrpc/app_bsky_feed.rs b/src/server/xrpc/app_bsky_feed.rs index 65b41c0..0861f9e 100644 --- a/src/server/xrpc/app_bsky_feed.rs +++ b/src/server/xrpc/app_bsky_feed.rs @@ -2,17 +2,206 @@ use crate::server::xrpc::{XrpcErrorResponse, resolve_author_context}; use crate::state::AppState; use axum::{Json, Router, extract::State}; use jacquard::{ + IntoStatic, + client::AgentSessionExt, + deps::fluent_uri::Uri, prelude::{HttpClient, XrpcClient}, - types::{datetime::Datetime, ident::AtIdentifier, string::Nsid}, + types::{datetime::Datetime, did::Did, ident::AtIdentifier, string::Nsid, uri::UriValue}, }; use jacquard_api::app_bsky::actor::ProfileViewBasic; +use jacquard_api::app_bsky::embed::record::{View as RecordView, ViewRecord, ViewUnionRecord}; +use jacquard_api::app_bsky::embed::record_with_media::{self, RecordWithMediaMedia, ViewMedia}; +use jacquard_api::app_bsky::embed::{external, images, video}; use jacquard_api::app_bsky::feed::get_author_feed::{GetAuthorFeedOutput, GetAuthorFeedRequest}; -use jacquard_api::app_bsky::feed::{FeedViewPost, PostView}; +use jacquard_api::app_bsky::feed::post::{Post, PostEmbed}; +use jacquard_api::app_bsky::feed::{FeedViewPost, PostView, PostViewEmbed}; use jacquard_api::com_atproto::repo::list_records::{ListRecords, ListRecordsResponse}; +use jacquard_api::com_atproto::repo::strong_ref::StrongRef; use jacquard_axum::{ExtractXrpc, IntoRouter, service_auth::ExtractOptionalServiceAuth}; use jacquard_common::xrpc; use log::{debug, info}; +fn blob_url(pds_url: &Uri, did: &Did<'_>, cid: &str) -> Option> { + UriValue::new_owned(format!( + "{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" + )) + .ok() +} + +fn images_view( + images_embed: images::Images<'static>, + pds_url: &Uri, + did: &Did<'_>, +) -> images::View<'static> { + let view_images = images_embed + .images + .into_iter() + .filter_map(|img| { + let uri = blob_url(pds_url, did, img.image.blob().cid().as_ref())?; + Some(images::ViewImage { + alt: img.alt, + aspect_ratio: img.aspect_ratio, + fullsize: uri.clone(), + thumb: uri, + extra_data: Default::default(), + }) + }) + .collect(); + images::View { + images: view_images, + extra_data: Default::default(), + } +} + +fn video_view( + video_embed: video::Video<'static>, + pds_url: &Uri, + did: &Did<'_>, +) -> Option> { + let cid = video_embed.video.blob().cid().clone(); + let playlist = blob_url(pds_url, did, cid.as_ref())?; + let presentation = video_embed + .presentation + .map(|p| video::ViewPresentation::from(p.as_str().to_string())); + Some(video::View { + alt: video_embed.alt, + aspect_ratio: video_embed.aspect_ratio, + cid, + playlist, + presentation, + thumbnail: None, + extra_data: Default::default(), + }) +} + +fn external_view( + source: external::External<'static>, + pds_url: &Uri, + did: &Did<'_>, +) -> external::View<'static> { + let thumb = source + .thumb + .as_ref() + .and_then(|b| blob_url(pds_url, did, b.blob().cid().as_ref())); + external::View { + external: external::ViewExternal { + description: source.description, + thumb, + title: source.title, + uri: source.uri, + extra_data: Default::default(), + }, + extra_data: Default::default(), + } +} + +fn media_to_view( + media: RecordWithMediaMedia<'static>, + pds_url: &Uri, + did: &Did<'_>, +) -> Option> { + match media { + RecordWithMediaMedia::Images(images_embed) => Some(ViewMedia::ImagesView(Box::new( + images_view(*images_embed, pds_url, did), + ))), + RecordWithMediaMedia::Video(video_embed) => { + video_view(*video_embed, pds_url, did).map(|v| ViewMedia::VideoView(Box::new(v))) + } + RecordWithMediaMedia::External(external_embed) => Some(ViewMedia::ExternalView(Box::new( + external_view(external_embed.external, pds_url, did), + ))), + RecordWithMediaMedia::Unknown(_) => None, + } +} + +async fn fetch_view_record( + state: &AppState, + strong_ref: StrongRef<'static>, +) -> Option> { + let fetched = match state.agent.fetch_record_slingshot(&strong_ref.uri).await { + Ok(f) => f, + Err(err) => { + log::warn!("fetch quoted record {}: {err:#}", strong_ref.uri); + return None; + } + }; + + let author_id = strong_ref.uri.authority().clone().into_static(); + let ctx = resolve_author_context(state, author_id).await.ok()?; + let author = ProfileViewBasic { + associated: None, + avatar: ctx.avatar_uri, + created_at: ctx.profile.created_at, + debug: None, + did: ctx.did, + display_name: ctx.profile.display_name, + handle: ctx.handle, + labels: None, + pronouns: ctx.profile.pronouns, + status: None, + verification: None, + viewer: None, + extra_data: Default::default(), + }; + + let indexed_at = jacquard::common::from_data_owned::>(fetched.value.clone()) + .ok() + .map(|p| p.created_at) + .unwrap_or_else(Datetime::now); + + Some(ViewRecord { + author, + cid: fetched.cid.unwrap_or(strong_ref.cid), + embeds: None, + indexed_at, + labels: None, + like_count: None, + quote_count: None, + reply_count: None, + repost_count: None, + uri: fetched.uri, + value: fetched.value, + extra_data: Default::default(), + }) +} + +async fn post_embed_to_view( + embed: PostEmbed<'static>, + state: &AppState, + pds_url: &Uri, + did: &Did<'_>, +) -> Option> { + match embed { + PostEmbed::Images(images_embed) => Some(PostViewEmbed::ImagesView(Box::new(images_view( + *images_embed, + pds_url, + did, + )))), + PostEmbed::Video(video_embed) => { + video_view(*video_embed, pds_url, did).map(|v| PostViewEmbed::VideoView(Box::new(v))) + } + PostEmbed::External(external_embed) => Some(PostViewEmbed::ExternalView(Box::new( + external_view(external_embed.external, pds_url, did), + ))), + PostEmbed::RecordWithMedia(record_with_media) => { + let media = media_to_view(record_with_media.media, pds_url, did)?; + let view_record = fetch_view_record(state, record_with_media.record.record).await?; + let record = RecordView { + record: ViewUnionRecord::ViewRecord(Box::new(view_record)), + extra_data: Default::default(), + }; + Some(PostViewEmbed::RecordWithMediaView(Box::new( + record_with_media::View { + media, + record, + extra_data: Default::default(), + }, + ))) + } + PostEmbed::Record(_) | PostEmbed::Unknown(_) => None, + } +} + pub async fn get_author_feed( State(state): State, ExtractOptionalServiceAuth(_auth): ExtractOptionalServiceAuth, @@ -74,38 +263,42 @@ pub async fn get_author_feed( XrpcErrorResponse::internal_server_error() })?; - let feed = output - .records - .into_iter() - .map(|record| { - let post = PostView { - author: author.clone(), - bookmark_count: None, - cid: record.cid, - debug: None, - embed: None, - indexed_at: Datetime::now(), - labels: None, - like_count: None, - quote_count: None, - record: record.value, - reply_count: None, - repost_count: None, - threadgate: None, - uri: record.uri, - viewer: None, - extra_data: Default::default(), - }; - FeedViewPost { - feed_context: None, - post, - reason: None, - reply: None, - req_id: None, - extra_data: Default::default(), - } - }) - .collect(); + let mut feed = Vec::with_capacity(output.records.len()); + for record in output.records { + let embed_source = jacquard::common::from_data_owned::>(record.value.clone()) + .ok() + .and_then(|post| post.embed); + let embed = match embed_source { + Some(e) => post_embed_to_view(e, &state, &ctx.pds_url, &ctx.did).await, + None => None, + }; + let post = PostView { + author: author.clone(), + bookmark_count: None, + cid: record.cid, + debug: None, + embed, + indexed_at: Datetime::now(), + labels: None, + like_count: None, + quote_count: None, + record: record.value, + reply_count: None, + repost_count: None, + threadgate: None, + uri: record.uri, + viewer: None, + extra_data: Default::default(), + }; + feed.push(FeedViewPost { + feed_context: None, + post, + reason: None, + reply: None, + req_id: None, + extra_data: Default::default(), + }); + } Ok(Json(GetAuthorFeedOutput { cursor: output.cursor,