🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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(&note.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 sea_orm::Condition;

        use crate::entities::ap_reactions;

        // 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 sea_orm::Condition;

        use crate::entities::ap_reactions;

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