Skip to main content

ojak_vocab/
meaning.rs

1//! What a post or a reaction means, where the fediverse says it more than
2//! one way.
3//!
4//! Reading gives every property a document carries; it does not decide which
5//! of several properties an application should treat as the quote, or
6//! whether a `Like` is a like or a reaction. These do, the way Mastodon,
7//! Misskey and Pleroma between them say those things, so that an application
8//! does not learn each spelling from a bug report. Each is a small function
9//! over the generated types, and an application that disagrees reads the
10//! fields itself.
11
12use crate::Iri;
13use crate::generated::{
14    AnyObject, Article, ChatMessage, Emoji, EmojiReact, Like, LinkOrObject, Note, Page, Question,
15};
16use crate::json::Text;
17use alloc::{string::String, vec::Vec};
18
19/// Someone a post mentions.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct Mention<'a> {
22    /// The actor.
23    pub href: &'a Iri,
24    /// How the post names them, such as `@alice@example.com`, if it does.
25    pub name: Option<&'a str>,
26}
27
28/// What a post says, the same way for every kind of post.
29pub trait Post {
30    /// The post's content warning: its `summary`, which Mastodon and Misskey
31    /// both show in front of the content, collapsed. `None` when it has none.
32    fn content_warning(&self) -> Option<&str>;
33
34    /// Whether the post's media is marked sensitive.
35    fn is_sensitive(&self) -> bool;
36
37    /// The people the post mentions, from its `tag`.
38    fn mentions(&self) -> Vec<Mention<'_>>;
39
40    /// The hashtags the post carries, from its `tag`, without their `#`.
41    fn hashtags(&self) -> Vec<&str>;
42
43    /// The post this one quotes: FEP-044f's `quote`, and failing that the
44    /// property Misskey (`_misskey_quote`), Fedibird (`quoteUri`) or Akkoma
45    /// (`quoteUrl`) sends it under, which reading merges into one.
46    fn quote(&self) -> Option<&Iri>;
47}
48
49/// The first value of a text, in no language or else in any.
50fn first(text: &Text) -> Option<&str> {
51    text.value
52        .as_deref()
53        .or_else(|| text.languages.values().next().map(String::as_str))
54        .filter(|value| !value.is_empty())
55}
56
57fn mentions(tags: &[LinkOrObject]) -> Vec<Mention<'_>> {
58    tags.iter()
59        .filter_map(|tag| match tag {
60            LinkOrObject::Mention(mention) => Some(Mention {
61                href: mention.href.as_ref()?,
62                name: first(&mention.name),
63            }),
64            _ => None,
65        })
66        .collect()
67}
68
69fn hashtags(tags: &[LinkOrObject]) -> Vec<&str> {
70    tags.iter()
71        .filter_map(|tag| match tag {
72            LinkOrObject::Hashtag(hashtag) => {
73                let name = first(&hashtag.name)?;
74                let name = name.strip_prefix('#').unwrap_or(name);
75                (!name.is_empty()).then_some(name)
76            }
77            _ => None,
78        })
79        .collect()
80}
81
82macro_rules! post {
83    ($($type:ty),*) => {$(
84        impl Post for $type {
85            fn content_warning(&self) -> Option<&str> {
86                first(&self.summary)
87            }
88
89            fn is_sensitive(&self) -> bool {
90                self.sensitive.unwrap_or(false)
91            }
92
93            fn mentions(&self) -> Vec<Mention<'_>> {
94                mentions(&self.tags)
95            }
96
97            fn hashtags(&self) -> Vec<&str> {
98                hashtags(&self.tags)
99            }
100
101            fn quote(&self) -> Option<&Iri> {
102                self.quote
103                    .as_ref()
104                    .and_then(AnyObject::id)
105                    .or(self.quote_url.as_ref())
106            }
107        }
108    )*};
109}
110
111post!(Note, Article, Question, ChatMessage);
112
113// Lemmy's posts, which quote nothing.
114impl Post for Page {
115    fn content_warning(&self) -> Option<&str> {
116        first(&self.summary)
117    }
118
119    fn is_sensitive(&self) -> bool {
120        self.sensitive.unwrap_or(false)
121    }
122
123    fn mentions(&self) -> Vec<Mention<'_>> {
124        mentions(&self.tags)
125    }
126
127    fn hashtags(&self) -> Vec<&str> {
128        hashtags(&self.tags)
129    }
130
131    fn quote(&self) -> Option<&Iri> {
132        None
133    }
134}
135
136/// An emoji reaction.
137#[derive(Clone, Copy, Debug, PartialEq)]
138pub struct Reaction<'a> {
139    /// The emoji: a Unicode emoji, or a custom one's shortcode, `:name:`.
140    pub content: &'a str,
141    /// The custom emoji the shortcode names, with its image, when it is one.
142    pub emoji: Option<&'a Emoji>,
143}
144
145fn reaction<'a>(content: &'a Text, tags: &'a [LinkOrObject]) -> Option<Reaction<'a>> {
146    let content = first(content)?;
147    let emoji = tags.iter().find_map(|tag| match tag {
148        LinkOrObject::Emoji(emoji) if first(&emoji.name) == Some(content) => Some(&**emoji),
149        _ => None,
150    });
151    Some(Reaction { content, emoji })
152}
153
154/// The reaction a `Like` carries, if it carries one: Misskey sends every
155/// reaction as a `Like` with the emoji as its `content`, and a `Like` with
156/// no content is a like.
157#[must_use]
158pub fn like_reaction(like: &Like) -> Option<Reaction<'_>> {
159    reaction(&like.content, &like.tags)
160}
161
162/// The reaction an `EmojiReact` carries, as Pleroma, Akkoma and Misskey send
163/// it.
164#[must_use]
165pub fn emoji_reaction(react: &EmojiReact) -> Option<Reaction<'_>> {
166    reaction(&react.content, &react.tags)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::generated::AnyObject;
173    use crate::{Registry, read_reporting};
174    use serde_json::{Value, json};
175
176    fn read(document: Value) -> AnyObject {
177        read_reporting::<AnyObject>(&Registry::bundled(), &document)
178            .expect("read")
179            .into_value()
180    }
181
182    fn note(document: Value) -> Note {
183        match read(document) {
184            AnyObject::Note(note) => *note,
185            other => panic!("not a Note: {other:?}"),
186        }
187    }
188
189    const MASTODON: &str = "https://www.w3.org/ns/activitystreams";
190
191    #[test]
192    fn a_mastodon_post_says_its_warning_mentions_and_tags() {
193        let note = note(json!({
194            "@context": [MASTODON, {"sensitive": "as:sensitive", "Hashtag": "as:Hashtag"}],
195            "id": "https://m.example/users/a/statuses/1",
196            "type": "Note",
197            "summary": "spoilers",
198            "sensitive": true,
199            "content": "<p>hi @b #Art</p>",
200            "tag": [
201                {"type": "Mention", "href": "https://n.example/users/b", "name": "@b@n.example"},
202                {"type": "Hashtag", "href": "https://m.example/tags/art", "name": "#Art"}
203            ]
204        }));
205        assert_eq!(note.content_warning(), Some("spoilers"));
206        assert!(note.is_sensitive());
207        let mentions = note.mentions();
208        assert_eq!(mentions.len(), 1);
209        assert_eq!(mentions[0].href.as_str(), "https://n.example/users/b");
210        assert_eq!(mentions[0].name, Some("@b@n.example"));
211        assert_eq!(note.hashtags(), ["Art"]);
212        assert_eq!(note.quote(), None);
213    }
214
215    #[test]
216    fn a_quote_is_found_under_every_name_it_is_sent_by() {
217        let quoted = "https://q.example/notes/1";
218        for (context, key) in [
219            (
220                json!({"quote": {"@id": "https://w3id.org/fep/044f#quote", "@type": "@id"}}),
221                "quote",
222            ),
223            (json!({"quoteUrl": "as:quoteUrl"}), "quoteUrl"),
224            (
225                json!({"misskey": "https://misskey-hub.net/ns#", "_misskey_quote": "misskey:_misskey_quote"}),
226                "_misskey_quote",
227            ),
228            (
229                json!({"fedibird": "http://fedibird.com/ns#", "quoteUri": "fedibird:quoteUri"}),
230                "quoteUri",
231            ),
232        ] {
233            let note = note(json!({
234                "@context": [MASTODON, context],
235                "id": "https://p.example/notes/2",
236                "type": "Note",
237                key: quoted,
238            }));
239            assert_eq!(note.quote().map(|iri| iri.as_str()), Some(quoted), "{key}");
240        }
241    }
242
243    #[test]
244    fn a_misskey_like_with_an_emoji_is_a_reaction() {
245        let like = match read(json!({
246            "@context": [MASTODON, {"toot": "http://joinmastodon.org/ns#", "Emoji": "toot:Emoji"}],
247            "id": "https://mk.example/likes/1",
248            "type": "Like",
249            "actor": "https://mk.example/users/1",
250            "object": "https://m.example/users/a/statuses/1",
251            "content": ":blobcat:",
252            "tag": [{
253                "id": "https://mk.example/emojis/blobcat",
254                "type": "Emoji",
255                "name": ":blobcat:",
256                "icon": {"type": "Image", "url": "https://mk.example/files/blobcat.png"}
257            }]
258        })) {
259            AnyObject::Like(like) => like,
260            other => panic!("{other:?}"),
261        };
262        let reaction = like_reaction(&like).expect("a reaction");
263        assert_eq!(reaction.content, ":blobcat:");
264        assert!(reaction.emoji.is_some_and(|emoji| !emoji.icons.is_empty()));
265
266        let plain = match read(json!({
267            "@context": MASTODON,
268            "id": "https://m.example/likes/2",
269            "type": "Like",
270            "actor": "https://m.example/users/a",
271            "object": "https://m.example/users/a/statuses/1"
272        })) {
273            AnyObject::Like(like) => like,
274            other => panic!("{other:?}"),
275        };
276        assert_eq!(
277            like_reaction(&plain),
278            None,
279            "a like with no content is a like"
280        );
281    }
282
283    #[test]
284    fn a_pleroma_emoji_react_is_a_reaction() {
285        let react = match read(json!({
286            "@context": [MASTODON, {"litepub": "http://litepub.social/ns#", "EmojiReact": "litepub:EmojiReact"}],
287            "id": "https://p.example/activities/1",
288            "type": "EmojiReact",
289            "actor": "https://p.example/users/a",
290            "object": "https://m.example/users/a/statuses/1",
291            "content": "👍"
292        })) {
293            AnyObject::EmojiReact(react) => react,
294            other => panic!("{other:?}"),
295        };
296        let reaction = emoji_reaction(&react).expect("a reaction");
297        assert_eq!(reaction.content, "👍");
298        assert_eq!(reaction.emoji, None);
299    }
300}