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