1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct Mention<'a> {
22 pub href: &'a Iri,
24 pub name: Option<&'a str>,
26}
27
28pub trait Post {
30 fn content_warning(&self) -> Option<&str>;
33
34 fn is_sensitive(&self) -> bool;
36
37 fn mentions(&self) -> Vec<Mention<'_>>;
39
40 fn hashtags(&self) -> Vec<&str>;
42
43 fn quote(&self) -> Option<&Iri>;
47}
48
49fn 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
113impl 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#[derive(Clone, Copy, Debug, PartialEq)]
138pub struct Reaction<'a> {
139 pub content: &'a str,
141 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#[must_use]
158pub fn like_reaction(like: &Like) -> Option<Reaction<'_>> {
159 reaction(&like.content, &like.tags)
160}
161
162#[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}