Skip to main content

threads_rs/types/
common.rs

1use serde::{Deserialize, Serialize};
2
3use super::ids::{ContainerId, PostId, UserId};
4use super::time::ThreadsTime;
5
6/// Controls who can reply to a post.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ReplyControl {
9    /// Anyone can reply.
10    #[serde(rename = "everyone")]
11    Everyone,
12    /// Only accounts the poster follows can reply.
13    #[serde(rename = "accounts_you_follow")]
14    AccountsYouFollow,
15    /// Only mentioned accounts can reply.
16    #[serde(rename = "mentioned_only")]
17    MentionedOnly,
18    /// Only the parent post author can reply.
19    #[serde(rename = "parent_post_author_only")]
20    ParentPostAuthorOnly,
21    /// Only followers can reply.
22    #[serde(rename = "followers_only")]
23    FollowersOnly,
24}
25
26/// Approval status for pending replies.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub enum ApprovalStatus {
29    /// Reply is awaiting approval.
30    #[serde(rename = "pending")]
31    Pending,
32    /// Reply has been ignored.
33    #[serde(rename = "ignored")]
34    Ignored,
35}
36
37/// Search result ordering.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub enum SearchType {
40    /// Order results by relevance.
41    #[serde(rename = "TOP")]
42    Top,
43    /// Order results by recency.
44    #[serde(rename = "RECENT")]
45    Recent,
46}
47
48/// Search mode selector.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub enum SearchMode {
51    /// Search by keyword.
52    #[serde(rename = "KEYWORD")]
53    Keyword,
54    /// Search by hashtag.
55    #[serde(rename = "TAG")]
56    Tag,
57}
58
59/// GIF provider.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub enum GifProvider {
62    /// Tenor GIF provider. Deprecated: Tenor API will be sunsetted by March 31, 2026.
63    #[serde(rename = "TENOR")]
64    Tenor,
65    /// Giphy GIF provider.
66    #[serde(rename = "GIPHY")]
67    Giphy,
68}
69
70/// Reply audience setting returned by the API (UPPERCASE values).
71///
72/// This is the read-side counterpart to [`ReplyControl`] which uses lowercase values.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub enum ReplyAudience {
75    /// Anyone can reply.
76    #[serde(rename = "EVERYONE")]
77    Everyone,
78    /// Only accounts the poster follows can reply.
79    #[serde(rename = "ACCOUNTS_YOU_FOLLOW")]
80    AccountsYouFollow,
81    /// Only mentioned accounts can reply.
82    #[serde(rename = "MENTIONED_ONLY")]
83    MentionedOnly,
84    /// Only the parent post author can reply.
85    #[serde(rename = "PARENT_POST_AUTHOR_ONLY")]
86    ParentPostAuthorOnly,
87    /// Only followers can reply.
88    #[serde(rename = "FOLLOWERS_ONLY")]
89    FollowersOnly,
90}
91
92/// Visibility/moderation status of a post.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub enum HideStatus {
95    /// Post is not hushed.
96    #[serde(rename = "NOT_HUSHED")]
97    NotHushed,
98    /// Post was unhushed.
99    #[serde(rename = "UNHUSHED")]
100    Unhushed,
101    /// Post is hidden.
102    #[serde(rename = "HIDDEN")]
103    Hidden,
104    /// Post is covered.
105    #[serde(rename = "COVERED")]
106    Covered,
107    /// Post is blocked.
108    #[serde(rename = "BLOCKED")]
109    Blocked,
110    /// Post is restricted.
111    #[serde(rename = "RESTRICTED")]
112    Restricted,
113}
114
115/// Media type for posts.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub enum MediaType {
118    /// Text-only post.
119    #[serde(rename = "TEXT")]
120    Text,
121    /// Single image post.
122    #[serde(rename = "IMAGE")]
123    Image,
124    /// Single video post.
125    #[serde(rename = "VIDEO")]
126    Video,
127    /// Audio post.
128    #[serde(rename = "AUDIO")]
129    Audio,
130    /// Carousel post (multiple media items).
131    #[serde(rename = "CAROUSEL")]
132    Carousel,
133    /// Carousel album container.
134    #[serde(rename = "CAROUSEL_ALBUM")]
135    CarouselAlbum,
136    /// Repost facade.
137    #[serde(rename = "REPOST_FACADE")]
138    RepostFacade,
139    /// Text post (response-only media type from API).
140    #[serde(rename = "TEXT_POST")]
141    TextPost,
142}
143
144/// Poll options when creating a post with a poll.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct PollAttachment {
147    /// First poll option text.
148    pub option_a: String,
149    /// Second poll option text.
150    pub option_b: String,
151    /// Third poll option (optional).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub option_c: Option<String>,
154    /// Fourth poll option (optional).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub option_d: Option<String>,
157}
158
159/// Poll results and voting statistics.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PollResult {
162    /// First poll option text.
163    pub option_a: String,
164    /// Second poll option text.
165    pub option_b: String,
166    /// Third poll option (optional).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub option_c: Option<String>,
169    /// Fourth poll option (optional).
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub option_d: Option<String>,
172    /// Vote percentage for option A.
173    pub option_a_votes_percentage: f64,
174    /// Vote percentage for option B.
175    pub option_b_votes_percentage: f64,
176    /// Vote percentage for option C.
177    #[serde(default)]
178    pub option_c_votes_percentage: f64,
179    /// Vote percentage for option D.
180    #[serde(default)]
181    pub option_d_votes_percentage: f64,
182    /// Total number of votes cast.
183    pub total_votes: i64,
184    /// When the poll expires.
185    pub expiration_timestamp: ThreadsTime,
186}
187
188/// Spoiler entity within text content.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TextEntity {
191    /// Entity type identifier.
192    pub entity_type: String,
193    /// Character offset where the entity starts.
194    pub offset: usize,
195    /// Character length of the entity.
196    pub length: usize,
197}
198
199/// Text attachment with optional styling (up to 10,000 chars).
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct TextAttachment {
202    /// Plain text content.
203    pub plaintext: String,
204    /// Optional link attachment URL.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub link_attachment_url: Option<String>,
207    /// Optional styling information for text ranges.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub text_with_styling_info: Option<Vec<TextStylingInfo>>,
210}
211
212/// Styling information for a range of text.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct TextStylingInfo {
215    /// Character offset where styling starts.
216    pub offset: usize,
217    /// Character length of the styled range.
218    pub length: usize,
219    /// List of styling tags applied.
220    pub styling_info: Vec<String>,
221}
222
223/// GIF attachment for text posts.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct GifAttachment {
226    /// GIF identifier from the provider.
227    pub gif_id: String,
228    /// GIF provider (Tenor or Giphy).
229    pub provider: GifProvider,
230}
231
232/// Owner of a post.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct PostOwner {
235    /// User ID of the owner.
236    pub id: UserId,
237}
238
239/// Children data for carousel posts.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ChildrenData {
242    /// List of child posts.
243    pub data: Vec<ChildPost>,
244}
245
246/// A child post in a carousel.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct ChildPost {
249    /// Child post ID.
250    pub id: PostId,
251}
252
253/// Status of a media container.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ContainerStatus {
256    /// Container ID.
257    pub id: ContainerId,
258    /// Current container status.
259    pub status: String,
260    /// Error message if status is ERROR.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub error_message: Option<String>,
263}
264
265/// Content for creating a repost.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct RepostContent {
268    /// ID of the post to repost.
269    pub post_id: PostId,
270}
271
272/// Quota configuration for a specific operation type.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct QuotaConfig {
275    /// Total quota allowed.
276    pub quota_total: i64,
277    /// Quota window duration in seconds.
278    pub quota_duration: i64,
279}
280
281/// Response wrapper for text entities.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct TextEntitiesResponse {
284    /// List of text entities.
285    pub data: Vec<TextEntity>,
286}
287
288/// A recently searched keyword with timestamp.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct RecentSearch {
291    /// The search query string.
292    pub query: String,
293    /// Timestamp of when the search was performed, in **milliseconds** since Unix epoch.
294    pub timestamp: i64,
295}
296
297/// Current API quota usage and limits.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct PublishingLimits {
300    /// Post creation quota used.
301    pub quota_usage: i64,
302    /// Post creation quota config.
303    pub config: QuotaConfig,
304    /// Reply quota used.
305    pub reply_quota_usage: i64,
306    /// Reply quota config.
307    pub reply_config: QuotaConfig,
308    /// Delete quota used.
309    pub delete_quota_usage: i64,
310    /// Delete quota config.
311    pub delete_config: QuotaConfig,
312    /// Location search quota used.
313    pub location_search_quota_usage: i64,
314    /// Location search quota config.
315    pub location_search_config: QuotaConfig,
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_reply_control_serde() {
324        let rc = ReplyControl::AccountsYouFollow;
325        let json = serde_json::to_string(&rc).unwrap();
326        assert_eq!(json, r#""accounts_you_follow""#);
327        let back: ReplyControl = serde_json::from_str(&json).unwrap();
328        assert_eq!(back, ReplyControl::AccountsYouFollow);
329    }
330
331    #[test]
332    fn test_media_type_serde() {
333        let mt = MediaType::Carousel;
334        let json = serde_json::to_string(&mt).unwrap();
335        assert_eq!(json, r#""CAROUSEL""#);
336        let back: MediaType = serde_json::from_str(&json).unwrap();
337        assert_eq!(back, MediaType::Carousel);
338    }
339
340    #[test]
341    fn test_search_type_serde() {
342        let st = SearchType::Recent;
343        let json = serde_json::to_string(&st).unwrap();
344        assert_eq!(json, r#""RECENT""#);
345    }
346
347    #[test]
348    fn test_search_mode_serde() {
349        let sm = SearchMode::Tag;
350        let json = serde_json::to_string(&sm).unwrap();
351        assert_eq!(json, r#""TAG""#);
352    }
353
354    #[test]
355    fn test_gif_provider_serde() {
356        let gp = GifProvider::Giphy;
357        let json = serde_json::to_string(&gp).unwrap();
358        assert_eq!(json, r#""GIPHY""#);
359    }
360
361    #[test]
362    fn test_approval_status_serde() {
363        let s = ApprovalStatus::Pending;
364        let json = serde_json::to_string(&s).unwrap();
365        assert_eq!(json, r#""pending""#);
366    }
367
368    #[test]
369    fn test_poll_attachment_serde() {
370        let poll = PollAttachment {
371            option_a: "Yes".into(),
372            option_b: "No".into(),
373            option_c: None,
374            option_d: None,
375        };
376        let json = serde_json::to_string(&poll).unwrap();
377        assert!(!json.contains("option_c"));
378        let back: PollAttachment = serde_json::from_str(&json).unwrap();
379        assert_eq!(back.option_a, "Yes");
380    }
381
382    #[test]
383    fn test_reply_audience_serde() {
384        let ra = ReplyAudience::AccountsYouFollow;
385        let json = serde_json::to_string(&ra).unwrap();
386        assert_eq!(json, r#""ACCOUNTS_YOU_FOLLOW""#);
387        let back: ReplyAudience = serde_json::from_str(&json).unwrap();
388        assert_eq!(back, ReplyAudience::AccountsYouFollow);
389    }
390
391    #[test]
392    fn test_hide_status_serde() {
393        let hs = HideStatus::Hidden;
394        let json = serde_json::to_string(&hs).unwrap();
395        assert_eq!(json, r#""HIDDEN""#);
396        let back: HideStatus = serde_json::from_str(&json).unwrap();
397        assert_eq!(back, HideStatus::Hidden);
398    }
399
400    #[test]
401    fn test_media_type_audio_serde() {
402        let mt = MediaType::Audio;
403        let json = serde_json::to_string(&mt).unwrap();
404        assert_eq!(json, r#""AUDIO""#);
405        let back: MediaType = serde_json::from_str(&json).unwrap();
406        assert_eq!(back, MediaType::Audio);
407    }
408
409    #[test]
410    fn test_media_type_text_post_serde() {
411        let mt = MediaType::TextPost;
412        let json = serde_json::to_string(&mt).unwrap();
413        assert_eq!(json, r#""TEXT_POST""#);
414        let back: MediaType = serde_json::from_str(&json).unwrap();
415        assert_eq!(back, MediaType::TextPost);
416    }
417
418    #[test]
419    fn test_text_entities_response_serde() {
420        let resp = TextEntitiesResponse {
421            data: vec![TextEntity {
422                entity_type: "SPOILER".into(),
423                offset: 0,
424                length: 5,
425            }],
426        };
427        let json = serde_json::to_string(&resp).unwrap();
428        let back: TextEntitiesResponse = serde_json::from_str(&json).unwrap();
429        assert_eq!(back.data.len(), 1);
430        assert_eq!(back.data[0].entity_type, "SPOILER");
431    }
432
433    #[test]
434    fn test_recent_search_serde() {
435        let rs = RecentSearch {
436            query: "rust".into(),
437            timestamp: 1700000000,
438        };
439        let json = serde_json::to_string(&rs).unwrap();
440        let back: RecentSearch = serde_json::from_str(&json).unwrap();
441        assert_eq!(back.query, "rust");
442        assert_eq!(back.timestamp, 1700000000);
443    }
444
445    #[test]
446    fn test_publishing_limits_deserialize() {
447        let json = r#"{
448            "quota_usage": 10,
449            "config": {"quota_total": 100, "quota_duration": 86400},
450            "reply_quota_usage": 5,
451            "reply_config": {"quota_total": 50, "quota_duration": 86400},
452            "delete_quota_usage": 2,
453            "delete_config": {"quota_total": 25, "quota_duration": 86400},
454            "location_search_quota_usage": 1,
455            "location_search_config": {"quota_total": 10, "quota_duration": 86400}
456        }"#;
457        let limits: PublishingLimits = serde_json::from_str(json).unwrap();
458        assert_eq!(limits.quota_usage, 10);
459        assert_eq!(limits.location_search_quota_usage, 1);
460    }
461
462    #[test]
463    fn test_container_status_serde() {
464        let cs = ContainerStatus {
465            id: ContainerId::from("123"),
466            status: "FINISHED".into(),
467            error_message: None,
468        };
469        let json = serde_json::to_string(&cs).unwrap();
470        assert!(!json.contains("error_message"));
471    }
472}