forked from
parakeet.at/parakeet
Rust AppView - highly experimental!
1//! Post Entity - Content created by actors in the AT Protocol
2//!
3//! A Post represents a piece of content (text, images, etc.) created by an actor.
4//! Posts are identified by their actor_id + rkey combination.
5
6use diesel::prelude::*;
7use serde::{Deserialize, Serialize};
8use crate::infrastructure::persistence::arrays;
9use crate::infrastructure::persistence::composite::{ExtEmbed, VideoEmbed, ImageEmbed, FacetEmbed, PostLabelRecord};
10use crate::infrastructure::persistence::types::*;
11use crate::domain::value_objects::tid;
12use crate::domain::value_objects::tid_traits::HasTidRkey;
13use crate::{impl_tid_rkey, impl_tid_created_at};
14
15/// Post Entity - Represents content in the AT Protocol
16///
17/// # Invariants
18/// - actor_id + rkey combination must be unique
19/// - Parent/root references must form valid thread structures
20/// - Engagement counts must match array lengths when arrays are present
21#[derive(Clone, Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable)]
22#[diesel(table_name = crate::infrastructure::schema::posts)]
23#[diesel(primary_key(actor_id, rkey))]
24#[diesel(check_for_backend(diesel::pg::Pg))]
25pub struct Post {
26 // === Identity ===
27 pub actor_id: i32, // Author of the post
28 pub rkey: i64, // Record key (TID as i64)
29 pub cid: Vec<u8>, // Content identifier (32-byte digest)
30
31 // === Content ===
32 pub content: Option<Vec<u8>>, // Zstd-compressed post content
33 pub langs: arrays::LanguageCodeArray, // Language codes (ISO 639-1)
34 pub tags: arrays::TextArray, // Hashtags
35 pub tokens: Option<arrays::TextArray>, // Search tokens
36
37 // === Embeds ===
38 pub embed_type: Option<EmbedType>,
39 pub embed_subtype: Option<EmbedType>,
40 pub ext_embed: Option<ExtEmbed>,
41 pub video_embed: Option<VideoEmbed>,
42 pub image_1: Option<ImageEmbed>,
43 pub image_2: Option<ImageEmbed>,
44 pub image_3: Option<ImageEmbed>,
45 pub image_4: Option<ImageEmbed>,
46
47 // === Facets (rich text) ===
48 pub facet_1: Option<FacetEmbed>,
49 pub facet_2: Option<FacetEmbed>,
50 pub facet_3: Option<FacetEmbed>,
51 pub facet_4: Option<FacetEmbed>,
52 pub facet_5: Option<FacetEmbed>,
53 pub facet_6: Option<FacetEmbed>,
54 pub facet_7: Option<FacetEmbed>,
55 pub facet_8: Option<FacetEmbed>,
56 pub mentions: Option<Vec<Option<i32>>>,
57
58 // === Thread Structure ===
59 pub parent_post_actor_id: Option<i32>,
60 pub parent_post_rkey: Option<i64>,
61 pub root_post_actor_id: Option<i32>,
62 pub root_post_rkey: Option<i64>,
63 pub embedded_post_actor_id: Option<i32>, // Quote post
64 pub embedded_post_rkey: Option<i64>,
65
66 // === Status ===
67 pub status: PostStatus,
68 pub violates_threadgate: bool,
69 pub record_detached: Option<bool>,
70
71 // === Engagement Arrays (Denormalized) ===
72 pub like_actor_ids: Option<Vec<Option<i32>>>,
73 pub like_rkeys: Option<Vec<Option<i64>>>,
74 pub like_via_repost_data: Option<serde_json::Value>,
75 pub reply_actor_ids: Option<Vec<Option<i32>>>,
76 pub reply_rkeys: Option<Vec<Option<i64>>>,
77 pub quote_actor_ids: Option<Vec<Option<i32>>>,
78 pub quote_rkeys: Option<Vec<Option<i64>>>,
79 pub repost_actor_ids: Option<Vec<Option<i32>>>,
80 pub repost_rkeys: Option<Vec<Option<i64>>>,
81
82 // === Moderation ===
83 pub threadgate_allow: Option<arrays::ThreadgateRuleArray>,
84 pub threadgate_hidden_actor_ids: Option<Vec<Option<i32>>>,
85 pub threadgate_hidden_rkeys: Option<Vec<Option<i64>>>,
86 pub postgate_rules: Option<arrays::PostgateRuleArray>,
87 pub postgate_detached_actor_ids: Option<Vec<Option<i32>>>,
88 pub postgate_detached_rkeys: Option<Vec<Option<i64>>>,
89 pub labels: Option<Vec<Option<PostLabelRecord>>>,
90
91 // === Engagement Counts ===
92 pub like_count: i32,
93 pub repost_count: i32,
94 pub reply_count: i32,
95 pub quote_count: i32,
96}
97
98/// Information about a like on a post
99#[derive(Debug, Clone)]
100pub struct PostLikeInfo {
101 pub actor_id: i32,
102 pub rkey: i64,
103 pub via_repost_actor_id: Option<i32>,
104 pub via_repost_rkey: Option<i64>,
105}
106
107// === Domain Logic ===
108
109impl Post {
110 /// Check if this is a reply
111 pub fn is_reply(&self) -> bool {
112 self.parent_post_actor_id.is_some()
113 }
114
115 /// Check if this is a quote post
116 pub fn is_quote(&self) -> bool {
117 self.embedded_post_actor_id.is_some()
118 }
119
120 /// Check if this post has images
121 pub fn has_images(&self) -> bool {
122 self.image_1.is_some() ||
123 matches!(self.embed_type, Some(EmbedType::Images))
124 }
125
126 /// Check if this post has video
127 pub fn has_video(&self) -> bool {
128 self.video_embed.is_some() ||
129 matches!(self.embed_type, Some(EmbedType::Video))
130 }
131
132 /// Count the number of images
133 pub fn image_count(&self) -> usize {
134 let mut count = 0;
135 if self.image_1.is_some() { count += 1; }
136 if self.image_2.is_some() { count += 1; }
137 if self.image_3.is_some() { count += 1; }
138 if self.image_4.is_some() { count += 1; }
139 count
140 }
141
142 /// Get a specific like by index
143 pub fn get_like(&self, idx: usize) -> Option<PostLikeInfo> {
144 let actor_ids = self.like_actor_ids.as_ref()?;
145 let rkeys = self.like_rkeys.as_ref()?;
146
147 let actor_id = *actor_ids.get(idx)?.as_ref()?;
148 let rkey = *rkeys.get(idx)?.as_ref()?;
149
150 // Extract via_repost from JSONB if it exists
151 let (via_repost_actor_id, via_repost_rkey) = self
152 .like_via_repost_data
153 .as_ref()
154 .and_then(|json| json.get(actor_id.to_string()))
155 .and_then(|data| {
156 let actor = data.get("actor_id")?.as_i64()? as i32;
157 let rkey = data.get("rkey")?.as_i64()?;
158 Some((Some(actor), Some(rkey)))
159 })
160 .unwrap_or((None, None));
161
162 Some(PostLikeInfo {
163 actor_id,
164 rkey,
165 via_repost_actor_id,
166 via_repost_rkey,
167 })
168 }
169
170 /// Check if a specific actor has liked this post
171 pub fn is_liked_by(&self, actor_id: i32) -> bool {
172 if let Some(ref like_actors) = self.like_actor_ids {
173 like_actors.iter().any(|a| a == &Some(actor_id))
174 } else {
175 false
176 }
177 }
178
179 /// Check if a specific actor has reposted this post
180 pub fn is_reposted_by(&self, actor_id: i32) -> bool {
181 if let Some(ref repost_actors) = self.repost_actor_ids {
182 repost_actors.iter().any(|a| a == &Some(actor_id))
183 } else {
184 false
185 }
186 }
187
188 /// Check if this post is available (not deleted/forbidden)
189 pub fn is_available(&self) -> bool {
190 matches!(self.status, PostStatus::Complete | PostStatus::Stub)
191 }
192
193 /// Check if threadgate allows a specific actor to reply
194 pub fn can_reply(&self, _actor_id: i32) -> bool {
195 // If no threadgate, anyone can reply
196 if self.threadgate_allow.is_none() {
197 return true;
198 }
199
200 // TODO: Implement threadgate rule checking
201 // This would need to check the rules array
202 true
203 }
204}
205
206// Implement TID traits for Post
207impl_tid_rkey!(Post);
208impl_tid_created_at!(Post);