Skip to main content

proto_blue_api/moderation/
types.rs

1//! Moderation type definitions.
2
3use serde::{Deserialize, Serialize};
4
5/// User preference for how a label should be handled.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum LabelPreference {
9    Ignore,
10    Warn,
11    Hide,
12}
13
14/// UI context where moderation decisions are applied.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum UiContext {
17    ProfileList,
18    ProfileView,
19    ContentList,
20    ContentView,
21    ContentMedia,
22    Avatar,
23    Banner,
24    DisplayName,
25}
26
27/// What a label blurs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum LabelBlurs {
31    Content,
32    Media,
33    None,
34}
35
36/// Label severity.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum LabelSeverity {
40    Alert,
41    Inform,
42    None,
43}
44
45/// Flags on a label value definition.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LabelFlag {
48    NoOverride,
49    Adult,
50    Unauthed,
51    NoSelf,
52}
53
54/// A UI action that can be applied.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum BehaviorValue {
57    Blur,
58    Alert,
59    Inform,
60}
61
62/// Per-context behavior map for a label.
63#[derive(Debug, Clone, Default)]
64pub struct ModerationBehavior {
65    pub profile_list: Option<BehaviorValue>,
66    pub profile_view: Option<BehaviorValue>,
67    pub content_list: Option<BehaviorValue>,
68    pub content_view: Option<BehaviorValue>,
69    pub content_media: Option<BehaviorValue>,
70    pub avatar: Option<BehaviorValue>,
71    pub banner: Option<BehaviorValue>,
72    pub display_name: Option<BehaviorValue>,
73}
74
75impl ModerationBehavior {
76    #[must_use]
77    pub const fn get(&self, ctx: UiContext) -> Option<BehaviorValue> {
78        match ctx {
79            UiContext::ProfileList => self.profile_list,
80            UiContext::ProfileView => self.profile_view,
81            UiContext::ContentList => self.content_list,
82            UiContext::ContentView => self.content_view,
83            UiContext::ContentMedia => self.content_media,
84            UiContext::Avatar => self.avatar,
85            UiContext::Banner => self.banner,
86            UiContext::DisplayName => self.display_name,
87        }
88    }
89}
90
91/// An interpreted label value definition.
92#[derive(Debug, Clone)]
93pub struct LabelValueDefinition {
94    pub identifier: String,
95    pub configurable: bool,
96    pub default_setting: LabelPreference,
97    pub flags: Vec<LabelFlag>,
98    pub severity: LabelSeverity,
99    pub blurs: LabelBlurs,
100    pub behaviors: LabelBehaviors,
101}
102
103/// Behaviors for different label targets.
104#[derive(Debug, Clone)]
105pub struct LabelBehaviors {
106    pub account: ModerationBehavior,
107    pub profile: ModerationBehavior,
108    pub content: ModerationBehavior,
109}
110
111/// Where the moderation cause originated.
112#[derive(Debug, Clone)]
113pub enum ModerationCauseSource {
114    User,
115    List { uri: String, name: String },
116    Labeler { did: String },
117}
118
119/// The target a label was applied to.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum LabelTarget {
122    Account,
123    Profile,
124    Content,
125}
126
127/// A single cause for a moderation decision.
128#[derive(Debug, Clone)]
129pub enum ModerationCause {
130    Blocking {
131        source: ModerationCauseSource,
132        priority: u8,
133        downgraded: bool,
134    },
135    BlockedBy {
136        source: ModerationCauseSource,
137        priority: u8,
138        downgraded: bool,
139    },
140    BlockOther {
141        source: ModerationCauseSource,
142        priority: u8,
143        downgraded: bool,
144    },
145    Label {
146        source: ModerationCauseSource,
147        label: LabelData,
148        label_def: LabelValueDefinition,
149        target: LabelTarget,
150        setting: LabelPreference,
151        no_override: bool,
152        priority: u8,
153        downgraded: bool,
154    },
155    Muted {
156        source: ModerationCauseSource,
157        priority: u8,
158        downgraded: bool,
159    },
160    MuteWord {
161        source: ModerationCauseSource,
162        priority: u8,
163        downgraded: bool,
164    },
165    Hidden {
166        source: ModerationCauseSource,
167        priority: u8,
168        downgraded: bool,
169    },
170}
171
172impl ModerationCause {
173    #[must_use]
174    pub const fn priority(&self) -> u8 {
175        match self {
176            Self::Blocking { priority, .. }
177            | Self::BlockedBy { priority, .. }
178            | Self::BlockOther { priority, .. }
179            | Self::Label { priority, .. }
180            | Self::Muted { priority, .. }
181            | Self::MuteWord { priority, .. }
182            | Self::Hidden { priority, .. } => *priority,
183        }
184    }
185
186    #[must_use]
187    pub const fn is_downgraded(&self) -> bool {
188        match self {
189            Self::Blocking { downgraded, .. }
190            | Self::BlockedBy { downgraded, .. }
191            | Self::BlockOther { downgraded, .. }
192            | Self::Label { downgraded, .. }
193            | Self::Muted { downgraded, .. }
194            | Self::MuteWord { downgraded, .. }
195            | Self::Hidden { downgraded, .. } => *downgraded,
196        }
197    }
198
199    pub const fn set_downgraded(&mut self) {
200        match self {
201            Self::Blocking { downgraded, .. }
202            | Self::BlockedBy { downgraded, .. }
203            | Self::BlockOther { downgraded, .. }
204            | Self::Label { downgraded, .. }
205            | Self::Muted { downgraded, .. }
206            | Self::MuteWord { downgraded, .. }
207            | Self::Hidden { downgraded, .. } => *downgraded = true,
208        }
209    }
210}
211
212/// Minimal label data for moderation decisions.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct LabelData {
215    pub src: String,
216    pub uri: String,
217    pub val: String,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub neg: Option<bool>,
220}
221
222/// A muted word rule.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct MutedWord {
226    pub value: String,
227    pub targets: Vec<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub actor_target: Option<String>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub expires_at: Option<String>,
232}
233
234/// Per-labeler moderation preferences.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ModerationPrefsLabeler {
237    pub did: String,
238    pub labels: std::collections::HashMap<String, LabelPreference>,
239}
240
241/// User moderation preferences.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase")]
244pub struct ModerationPrefs {
245    pub adult_content_enabled: bool,
246    pub labels: std::collections::HashMap<String, LabelPreference>,
247    pub labelers: Vec<ModerationPrefsLabeler>,
248    pub muted_words: Vec<MutedWord>,
249    pub hidden_posts: Vec<String>,
250}
251
252/// Top-level moderation options passed to the decision engine.
253#[derive(Debug, Clone)]
254pub struct ModerationOpts {
255    pub user_did: Option<String>,
256    pub prefs: ModerationPrefs,
257    pub label_defs: std::collections::HashMap<String, Vec<LabelValueDefinition>>,
258}
259
260/// The output of applying a moderation decision to a UI context.
261#[derive(Debug, Clone, Default)]
262pub struct ModerationUi {
263    pub no_override: bool,
264    pub filters: Vec<ModerationCause>,
265    pub blurs: Vec<ModerationCause>,
266    pub alerts: Vec<ModerationCause>,
267    pub informs: Vec<ModerationCause>,
268}
269
270impl ModerationUi {
271    #[must_use]
272    pub fn filter(&self) -> bool {
273        !self.filters.is_empty()
274    }
275    #[must_use]
276    pub fn blur(&self) -> bool {
277        !self.blurs.is_empty()
278    }
279    #[must_use]
280    pub fn alert(&self) -> bool {
281        !self.alerts.is_empty()
282    }
283    #[must_use]
284    pub fn inform(&self) -> bool {
285        !self.informs.is_empty()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn label_preference_serde() {
295        let pref = LabelPreference::Hide;
296        let json = serde_json::to_string(&pref).unwrap();
297        assert_eq!(json, "\"hide\"");
298        let parsed: LabelPreference = serde_json::from_str(&json).unwrap();
299        assert_eq!(parsed, LabelPreference::Hide);
300    }
301
302    #[test]
303    fn moderation_ui_helpers() {
304        let mut ui = ModerationUi::default();
305        assert!(!ui.filter());
306        assert!(!ui.blur());
307        assert!(!ui.alert());
308        assert!(!ui.inform());
309
310        ui.filters.push(ModerationCause::Hidden {
311            source: ModerationCauseSource::User,
312            priority: 6,
313            downgraded: false,
314        });
315        assert!(ui.filter());
316    }
317
318    #[test]
319    fn cause_priority_and_downgrade() {
320        let mut cause = ModerationCause::Muted {
321            source: ModerationCauseSource::User,
322            priority: 6,
323            downgraded: false,
324        };
325        assert_eq!(cause.priority(), 6);
326        assert!(!cause.is_downgraded());
327        cause.set_downgraded();
328        assert!(cause.is_downgraded());
329    }
330
331    #[test]
332    fn muted_word_serde() {
333        let word = MutedWord {
334            value: "test".into(),
335            targets: vec!["content".into()],
336            actor_target: Some("exclude-following".into()),
337            expires_at: None,
338        };
339        let json = serde_json::to_string(&word).unwrap();
340        assert!(json.contains("\"actorTarget\""));
341        let parsed: MutedWord = serde_json::from_str(&json).unwrap();
342        assert_eq!(parsed.value, "test");
343    }
344
345    #[test]
346    fn behavior_get_context() {
347        let behavior = ModerationBehavior {
348            content_list: Some(BehaviorValue::Blur),
349            content_view: Some(BehaviorValue::Alert),
350            ..Default::default()
351        };
352        assert_eq!(
353            behavior.get(UiContext::ContentList),
354            Some(BehaviorValue::Blur)
355        );
356        assert_eq!(
357            behavior.get(UiContext::ContentView),
358            Some(BehaviorValue::Alert)
359        );
360        assert_eq!(behavior.get(UiContext::Avatar), None);
361    }
362}