//! Feed query logic for the home timeline.
//!
//! Unions remote notes from accepted followees with the user's own notes,
//! ordered by published date descending.
use chrono::{DateTime, FixedOffset};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use serde::Serialize;
use crate::entities::prelude::*;
use crate::entities::{ap_actors, ap_following, ap_notes, ap_remote_notes};
/// Reaction counts for a note: (likes, announces, emoji_reactions)
type ReactionCounts = (u64, u64, Vec<(String, u64)>);
/// A feed entry, either a local note or a remote note.
#[derive(Debug, Clone, Serialize)]
pub enum FeedEntry {
Local {
id: i64,
uuid: String,
content_html: String,
created_at: DateTime<FixedOffset>,
author_username: String,
author_icon_url: Option<String>,
reacted: bool,
boosted: bool,
likes: u64,
announces: u64,
emojis: Vec<(String, u64)>,
},
Remote {
id: i64,
remote_note_id: i64,
object_url: String,
content_html: String,
published: DateTime<FixedOffset>,
author_username: String,
author_host: String,
author_icon_url: Option<String>,
author_actor_url: String,
reacted: bool,
boosted: bool,
likes: u64,
announces: u64,
emojis: Vec<(String, u64)>,
},
}
impl FeedEntry {
/// Get the published/created timestamp for sorting.
pub fn timestamp(&self) -> DateTime<FixedOffset> {
match self {
FeedEntry::Local { created_at, .. } => *created_at,
FeedEntry::Remote { published, .. } => *published,
}
}
/// Get the content HTML.
pub fn content(&self) -> &str {
match self {
FeedEntry::Local { content_html, .. } => content_html,
FeedEntry::Remote { content_html, .. } => content_html,
}
}
/// Get a display-friendly author handle.
pub fn author_handle(&self) -> String {
match self {
FeedEntry::Local {
author_username, ..
} => author_username.clone(),
FeedEntry::Remote {
author_username,
author_host,
..
} => format!("{author_username}@{author_host}"),
}
}
}
/// Fetch the home feed for a user.
///
/// Returns up to `limit` entries, ordered by published date descending.
/// The `page` parameter (0-indexed) determines which slice of entries to return.
#[tracing::instrument(skip(db))]
pub async fn get_home_feed(
db: &DatabaseConnection,
user_id: i64,
public_url: &str,
limit: u64,
page: u64,
) -> Result<Vec<FeedEntry>, Box<dyn std::error::Error + Send + Sync>> {
// Find the local actor for this user
let local_actor = ApActors::find()
.filter(ap_actors::Column::UserId.eq(user_id))
.one(db)
.await?
.ok_or("user has no actor")?;
// Find the user to get their username
let user = Users::find_by_id(user_id)
.one(db)
.await?
.ok_or("user not found")?;
// Find all remote actors this user follows (accepted state)
let accepted_follows = ApFollowing::find()
.filter(ap_following::Column::LocalActorId.eq(local_actor.id))
.filter(ap_following::Column::State.eq("accepted"))
.all(db)
.await?;
let remote_actor_ids: Vec<i64> = accepted_follows.iter().map(|f| f.remote_actor_id).collect();
// Batch-fetch remote actors to avoid N+1 queries
let mut remote_actors_map = std::collections::HashMap::new();
if !remote_actor_ids.is_empty() {
let remote_actors = ApRemoteActors::find()
.filter(crate::entities::ap_remote_actors::Column::Id.is_in(remote_actor_ids.clone()))
.all(db)
.await?;
for actor in remote_actors {
remote_actors_map.insert(actor.id, actor);
}
}
// Fetch remote notes from those actors
let mut remote_notes = Vec::new();
let mut remote_note_ids = Vec::new();
let offset = page * limit;
let fetch_limit = limit + offset; // Fetch enough to cover pagination
if !remote_actor_ids.is_empty() {
let notes = ApRemoteNotes::find()
.filter(ap_remote_notes::Column::RemoteActorId.is_in(remote_actor_ids))
.order_by_desc(ap_remote_notes::Column::Published)
.limit(fetch_limit)
.all(db)
.await?;
for note in notes {
remote_note_ids.push(note.id);
let remote_actor = remote_actors_map
.get(¬e.remote_actor_id)
.ok_or("remote actor not found")?;
let author_username = remote_actor
.preferred_username
.clone()
.unwrap_or_else(|| "unknown".to_string());
let author_host = url::Url::parse(&remote_actor.actor_url)
.ok()
.and_then(|u| {
u.host_str().map(|h| {
if let Some(port) = u.port() {
format!("{}:{}", h, port)
} else {
h.to_string()
}
})
})
.unwrap_or_else(|| "unknown".to_string());
remote_notes.push(FeedEntry::Remote {
id: note.id,
remote_note_id: note.id,
object_url: note.object_url,
content_html: note.content_html,
published: note.published,
author_username,
author_host,
author_icon_url: remote_actor.icon_url.clone(),
author_actor_url: remote_actor.actor_url.clone(),
reacted: false, // Will be updated below
boosted: false, // Will be updated below
likes: 0, // Will be updated below
announces: 0, // Will be updated below
emojis: Vec::new(), // Will be updated below
});
}
}
// Fetch user's own notes
let local_notes = ApNotes::find()
.filter(ap_notes::Column::ActorId.eq(local_actor.id))
.order_by_desc(ap_notes::Column::CreatedAt)
.limit(fetch_limit)
.all(db)
.await?;
let mut local_entries = Vec::new();
let mut local_note_ids = Vec::new();
for note in local_notes {
local_note_ids.push(note.id);
local_entries.push(FeedEntry::Local {
id: note.id,
uuid: note.uuid.to_string(),
content_html: note.content_html,
created_at: note.created_at,
author_username: user.name.clone(),
author_icon_url: None, // Local users don't have avatars yet
reacted: false, // Will be updated below
boosted: false, // Will be updated below
likes: 0, // Will be updated below
announces: 0, // Will be updated below
emojis: Vec::new(), // Will be updated below
});
}
// Query viewer's reactions to determine which notes they've liked or boosted
let mut reacted_note_ids = std::collections::HashSet::new();
let mut reacted_remote_note_ids = std::collections::HashSet::new();
let mut boosted_note_ids = std::collections::HashSet::new();
let mut boosted_remote_note_ids = std::collections::HashSet::new();
if !local_note_ids.is_empty() || !remote_note_ids.is_empty() {
use crate::entities::ap_reactions;
use sea_orm::Condition;
// Query both like and announce reactions
let mut query =
ApReactions::find().filter(ap_reactions::Column::LocalUserId.eq(Some(user_id)));
// Build OR condition for note_id IN or remote_note_id IN
let mut or_condition = Condition::any();
if !local_note_ids.is_empty() {
or_condition =
or_condition.add(ap_reactions::Column::NoteId.is_in(local_note_ids.clone()));
}
if !remote_note_ids.is_empty() {
or_condition =
or_condition.add(ap_reactions::Column::RemoteNoteId.is_in(remote_note_ids.clone()));
}
query = query.filter(or_condition);
let reactions = query.all(db).await?;
for reaction in reactions {
let is_like = reaction.kind == "like";
let is_announce = reaction.kind == "announce";
if let Some(note_id) = reaction.note_id {
if is_like {
reacted_note_ids.insert(note_id);
}
if is_announce {
boosted_note_ids.insert(note_id);
}
}
if let Some(remote_note_id) = reaction.remote_note_id {
if is_like {
reacted_remote_note_ids.insert(remote_note_id);
}
if is_announce {
boosted_remote_note_ids.insert(remote_note_id);
}
}
}
}
// Update reacted and boosted flags for local notes
for entry in &mut local_entries {
if let FeedEntry::Local { id, reacted, .. } = entry {
*reacted = reacted_note_ids.contains(id);
// Local notes don't have boosted field, so we skip it
}
}
// Update reacted and boosted flags for remote notes
for entry in &mut remote_notes {
if let FeedEntry::Remote {
remote_note_id,
reacted,
boosted,
..
} = entry
{
*reacted = reacted_remote_note_ids.contains(remote_note_id);
*boosted = boosted_remote_note_ids.contains(remote_note_id);
}
}
// Query reaction counts for all notes
if !local_note_ids.is_empty() || !remote_note_ids.is_empty() {
use crate::entities::ap_reactions;
use sea_orm::Condition;
// Build OR condition for note_id IN or remote_note_id IN
let mut or_condition = Condition::any();
if !local_note_ids.is_empty() {
or_condition =
or_condition.add(ap_reactions::Column::NoteId.is_in(local_note_ids.clone()));
}
if !remote_note_ids.is_empty() {
or_condition =
or_condition.add(ap_reactions::Column::RemoteNoteId.is_in(remote_note_ids.clone()));
}
let all_reactions = ApReactions::find().filter(or_condition).all(db).await?;
// Aggregate counts by note
let mut local_counts: std::collections::HashMap<i64, ReactionCounts> =
std::collections::HashMap::new();
let mut remote_counts: std::collections::HashMap<i64, ReactionCounts> =
std::collections::HashMap::new();
for reaction in all_reactions {
let is_like = reaction.kind == "like";
let is_announce = reaction.kind == "announce";
if let Some(note_id) = reaction.note_id {
let entry = local_counts.entry(note_id).or_insert((0, 0, Vec::new()));
if is_like {
entry.0 += 1;
}
if is_announce {
entry.1 += 1;
}
if !is_like
&& !is_announce
&& let Some(ref emoji) = reaction.emoji
{
if let Some(existing) = entry.2.iter_mut().find(|(e, _)| e == emoji) {
existing.1 += 1;
} else {
entry.2.push((emoji.clone(), 1));
}
}
}
if let Some(remote_note_id) = reaction.remote_note_id {
let entry = remote_counts
.entry(remote_note_id)
.or_insert((0, 0, Vec::new()));
if is_like {
entry.0 += 1;
}
if is_announce {
entry.1 += 1;
}
if !is_like
&& !is_announce
&& let Some(ref emoji) = reaction.emoji
{
if let Some(existing) = entry.2.iter_mut().find(|(e, _)| e == emoji) {
existing.1 += 1;
} else {
entry.2.push((emoji.clone(), 1));
}
}
}
}
// Update local notes with counts
for entry in &mut local_entries {
if let FeedEntry::Local {
id,
likes,
announces,
emojis,
..
} = entry
&& let Some((l, a, e)) = local_counts.get(id)
{
*likes = *l;
*announces = *a;
*emojis = e.clone();
}
}
// Update remote notes with counts
for entry in &mut remote_notes {
if let FeedEntry::Remote {
remote_note_id,
likes,
announces,
emojis,
..
} = entry
&& let Some((l, a, e)) = remote_counts.get(remote_note_id)
{
*likes = *l;
*announces = *a;
*emojis = e.clone();
}
}
}
// Merge and sort by timestamp descending
let mut all_entries: Vec<FeedEntry> = remote_notes.into_iter().chain(local_entries).collect();
all_entries.sort_by_key(|a| std::cmp::Reverse(a.timestamp()));
// Apply pagination: skip offset items and take limit items
let offset_usize = offset as usize;
let limit_usize = limit as usize;
if offset_usize >= all_entries.len() {
all_entries.clear();
} else {
all_entries = all_entries
.into_iter()
.skip(offset_usize)
.take(limit_usize)
.collect();
}
Ok(all_entries)
}