]> Untitled Git - lemmy.git/blob - crates/db_views/src/comment_view.rs
Adding GetUnreadCount to the API. Fixes #1794 (#1842)
[lemmy.git] / crates / db_views / src / comment_view.rs
1 use diesel::{result::Error, *};
2 use lemmy_db_queries::{
3   aggregates::comment_aggregates::CommentAggregates,
4   functions::hot_rank,
5   fuzzy_search,
6   limit_and_offset,
7   ListingType,
8   MaybeOptional,
9   SortType,
10   ToSafe,
11   ViewToVec,
12 };
13 use lemmy_db_schema::{
14   schema::{
15     comment,
16     comment_aggregates,
17     comment_alias_1,
18     comment_like,
19     comment_saved,
20     community,
21     community_block,
22     community_follower,
23     community_person_ban,
24     person,
25     person_alias_1,
26     person_block,
27     post,
28   },
29   source::{
30     comment::{Comment, CommentAlias1, CommentSaved},
31     community::{Community, CommunityFollower, CommunityPersonBan, CommunitySafe},
32     person::{Person, PersonAlias1, PersonSafe, PersonSafeAlias1},
33     person_block::PersonBlock,
34     post::Post,
35   },
36   CommentId,
37   CommunityId,
38   DbUrl,
39   PersonId,
40   PostId,
41 };
42 use serde::Serialize;
43
44 #[derive(Debug, PartialEq, Serialize, Clone)]
45 pub struct CommentView {
46   pub comment: Comment,
47   pub creator: PersonSafe,
48   pub recipient: Option<PersonSafeAlias1>, // Left joins to comment and person
49   pub post: Post,
50   pub community: CommunitySafe,
51   pub counts: CommentAggregates,
52   pub creator_banned_from_community: bool, // Left Join to CommunityPersonBan
53   pub subscribed: bool,                    // Left join to CommunityFollower
54   pub saved: bool,                         // Left join to CommentSaved
55   pub creator_blocked: bool,               // Left join to PersonBlock
56   pub my_vote: Option<i16>,                // Left join to CommentLike
57 }
58
59 type CommentViewTuple = (
60   Comment,
61   PersonSafe,
62   Option<CommentAlias1>,
63   Option<PersonSafeAlias1>,
64   Post,
65   CommunitySafe,
66   CommentAggregates,
67   Option<CommunityPersonBan>,
68   Option<CommunityFollower>,
69   Option<CommentSaved>,
70   Option<PersonBlock>,
71   Option<i16>,
72 );
73
74 impl CommentView {
75   pub fn read(
76     conn: &PgConnection,
77     comment_id: CommentId,
78     my_person_id: Option<PersonId>,
79   ) -> Result<Self, Error> {
80     // The left join below will return None in this case
81     let person_id_join = my_person_id.unwrap_or(PersonId(-1));
82
83     let (
84       comment,
85       creator,
86       _parent_comment,
87       recipient,
88       post,
89       community,
90       counts,
91       creator_banned_from_community,
92       subscribed,
93       saved,
94       creator_blocked,
95       comment_like,
96     ) = comment::table
97       .find(comment_id)
98       .inner_join(person::table)
99       // recipient here
100       .left_join(comment_alias_1::table.on(comment_alias_1::id.nullable().eq(comment::parent_id)))
101       .left_join(person_alias_1::table.on(person_alias_1::id.eq(comment_alias_1::creator_id)))
102       .inner_join(post::table)
103       .inner_join(community::table.on(post::community_id.eq(community::id)))
104       .inner_join(comment_aggregates::table)
105       .left_join(
106         community_person_ban::table.on(
107           community::id
108             .eq(community_person_ban::community_id)
109             .and(community_person_ban::person_id.eq(comment::creator_id)),
110         ),
111       )
112       .left_join(
113         community_follower::table.on(
114           post::community_id
115             .eq(community_follower::community_id)
116             .and(community_follower::person_id.eq(person_id_join)),
117         ),
118       )
119       .left_join(
120         comment_saved::table.on(
121           comment::id
122             .eq(comment_saved::comment_id)
123             .and(comment_saved::person_id.eq(person_id_join)),
124         ),
125       )
126       .left_join(
127         person_block::table.on(
128           comment::creator_id
129             .eq(person_block::target_id)
130             .and(person_block::person_id.eq(person_id_join)),
131         ),
132       )
133       .left_join(
134         comment_like::table.on(
135           comment::id
136             .eq(comment_like::comment_id)
137             .and(comment_like::person_id.eq(person_id_join)),
138         ),
139       )
140       .select((
141         comment::all_columns,
142         Person::safe_columns_tuple(),
143         comment_alias_1::all_columns.nullable(),
144         PersonAlias1::safe_columns_tuple().nullable(),
145         post::all_columns,
146         Community::safe_columns_tuple(),
147         comment_aggregates::all_columns,
148         community_person_ban::all_columns.nullable(),
149         community_follower::all_columns.nullable(),
150         comment_saved::all_columns.nullable(),
151         person_block::all_columns.nullable(),
152         comment_like::score.nullable(),
153       ))
154       .first::<CommentViewTuple>(conn)?;
155
156     // If a person is given, then my_vote, if None, should be 0, not null
157     // Necessary to differentiate between other person's votes
158     let my_vote = if my_person_id.is_some() && comment_like.is_none() {
159       Some(0)
160     } else {
161       comment_like
162     };
163
164     Ok(CommentView {
165       comment,
166       recipient,
167       post,
168       creator,
169       community,
170       counts,
171       creator_banned_from_community: creator_banned_from_community.is_some(),
172       subscribed: subscribed.is_some(),
173       saved: saved.is_some(),
174       creator_blocked: creator_blocked.is_some(),
175       my_vote,
176     })
177   }
178
179   /// Gets the recipient person id.
180   /// If there is no parent comment, its the post creator
181   pub fn get_recipient_id(&self) -> PersonId {
182     match &self.recipient {
183       Some(parent_commenter) => parent_commenter.id,
184       None => self.post.creator_id,
185     }
186   }
187
188   /// Gets the number of unread replies
189   pub fn get_unread_replies(conn: &PgConnection, my_person_id: PersonId) -> Result<i64, Error> {
190     use diesel::dsl::*;
191
192     comment::table
193       // recipient here
194       .left_join(comment_alias_1::table.on(comment_alias_1::id.nullable().eq(comment::parent_id)))
195       .left_join(person_alias_1::table.on(person_alias_1::id.eq(comment_alias_1::creator_id)))
196       .inner_join(post::table)
197       .inner_join(community::table.on(post::community_id.eq(community::id)))
198       .left_join(
199         person_block::table.on(
200           comment::creator_id
201             .eq(person_block::target_id)
202             .and(person_block::person_id.eq(my_person_id)),
203         ),
204       )
205       .left_join(
206         community_block::table.on(
207           community::id
208             .eq(community_block::community_id)
209             .and(community_block::person_id.eq(my_person_id)),
210         ),
211       )
212       .filter(person_alias_1::id.eq(my_person_id)) // Gets the comment replies
213       .or_filter(
214         comment::parent_id
215           .is_null()
216           .and(post::creator_id.eq(my_person_id)),
217       ) // Gets the top level replies
218       .filter(comment::read.eq(false))
219       .filter(comment::deleted.eq(false))
220       .filter(comment::removed.eq(false))
221       // Don't show blocked communities or persons
222       .filter(community_block::person_id.is_null())
223       .filter(person_block::person_id.is_null())
224       .select(count(comment::id))
225       .first::<i64>(conn)
226   }
227 }
228
229 pub struct CommentQueryBuilder<'a> {
230   conn: &'a PgConnection,
231   listing_type: Option<ListingType>,
232   sort: Option<SortType>,
233   community_id: Option<CommunityId>,
234   community_actor_id: Option<DbUrl>,
235   post_id: Option<PostId>,
236   creator_id: Option<PersonId>,
237   recipient_id: Option<PersonId>,
238   my_person_id: Option<PersonId>,
239   search_term: Option<String>,
240   saved_only: Option<bool>,
241   unread_only: Option<bool>,
242   show_bot_accounts: Option<bool>,
243   page: Option<i64>,
244   limit: Option<i64>,
245 }
246
247 impl<'a> CommentQueryBuilder<'a> {
248   pub fn create(conn: &'a PgConnection) -> Self {
249     CommentQueryBuilder {
250       conn,
251       listing_type: None,
252       sort: None,
253       community_id: None,
254       community_actor_id: None,
255       post_id: None,
256       creator_id: None,
257       recipient_id: None,
258       my_person_id: None,
259       search_term: None,
260       saved_only: None,
261       unread_only: None,
262       show_bot_accounts: None,
263       page: None,
264       limit: None,
265     }
266   }
267
268   pub fn listing_type<T: MaybeOptional<ListingType>>(mut self, listing_type: T) -> Self {
269     self.listing_type = listing_type.get_optional();
270     self
271   }
272
273   pub fn sort<T: MaybeOptional<SortType>>(mut self, sort: T) -> Self {
274     self.sort = sort.get_optional();
275     self
276   }
277
278   pub fn post_id<T: MaybeOptional<PostId>>(mut self, post_id: T) -> Self {
279     self.post_id = post_id.get_optional();
280     self
281   }
282
283   pub fn creator_id<T: MaybeOptional<PersonId>>(mut self, creator_id: T) -> Self {
284     self.creator_id = creator_id.get_optional();
285     self
286   }
287
288   pub fn recipient_id<T: MaybeOptional<PersonId>>(mut self, recipient_id: T) -> Self {
289     self.recipient_id = recipient_id.get_optional();
290     self
291   }
292
293   pub fn community_id<T: MaybeOptional<CommunityId>>(mut self, community_id: T) -> Self {
294     self.community_id = community_id.get_optional();
295     self
296   }
297
298   pub fn my_person_id<T: MaybeOptional<PersonId>>(mut self, my_person_id: T) -> Self {
299     self.my_person_id = my_person_id.get_optional();
300     self
301   }
302
303   pub fn community_actor_id<T: MaybeOptional<DbUrl>>(mut self, community_actor_id: T) -> Self {
304     self.community_actor_id = community_actor_id.get_optional();
305     self
306   }
307
308   pub fn search_term<T: MaybeOptional<String>>(mut self, search_term: T) -> Self {
309     self.search_term = search_term.get_optional();
310     self
311   }
312
313   pub fn saved_only<T: MaybeOptional<bool>>(mut self, saved_only: T) -> Self {
314     self.saved_only = saved_only.get_optional();
315     self
316   }
317
318   pub fn unread_only<T: MaybeOptional<bool>>(mut self, unread_only: T) -> Self {
319     self.unread_only = unread_only.get_optional();
320     self
321   }
322
323   pub fn show_bot_accounts<T: MaybeOptional<bool>>(mut self, show_bot_accounts: T) -> Self {
324     self.show_bot_accounts = show_bot_accounts.get_optional();
325     self
326   }
327
328   pub fn page<T: MaybeOptional<i64>>(mut self, page: T) -> Self {
329     self.page = page.get_optional();
330     self
331   }
332
333   pub fn limit<T: MaybeOptional<i64>>(mut self, limit: T) -> Self {
334     self.limit = limit.get_optional();
335     self
336   }
337
338   pub fn list(self) -> Result<Vec<CommentView>, Error> {
339     use diesel::dsl::*;
340
341     // The left join below will return None in this case
342     let person_id_join = self.my_person_id.unwrap_or(PersonId(-1));
343
344     let mut query = comment::table
345       .inner_join(person::table)
346       // recipient here
347       .left_join(comment_alias_1::table.on(comment_alias_1::id.nullable().eq(comment::parent_id)))
348       .left_join(person_alias_1::table.on(person_alias_1::id.eq(comment_alias_1::creator_id)))
349       .inner_join(post::table)
350       .inner_join(community::table.on(post::community_id.eq(community::id)))
351       .inner_join(comment_aggregates::table)
352       .left_join(
353         community_person_ban::table.on(
354           community::id
355             .eq(community_person_ban::community_id)
356             .and(community_person_ban::person_id.eq(comment::creator_id)),
357         ),
358       )
359       .left_join(
360         community_follower::table.on(
361           post::community_id
362             .eq(community_follower::community_id)
363             .and(community_follower::person_id.eq(person_id_join)),
364         ),
365       )
366       .left_join(
367         comment_saved::table.on(
368           comment::id
369             .eq(comment_saved::comment_id)
370             .and(comment_saved::person_id.eq(person_id_join)),
371         ),
372       )
373       .left_join(
374         person_block::table.on(
375           comment::creator_id
376             .eq(person_block::target_id)
377             .and(person_block::person_id.eq(person_id_join)),
378         ),
379       )
380       .left_join(
381         community_block::table.on(
382           community::id
383             .eq(community_block::community_id)
384             .and(community_block::person_id.eq(person_id_join)),
385         ),
386       )
387       .left_join(
388         comment_like::table.on(
389           comment::id
390             .eq(comment_like::comment_id)
391             .and(comment_like::person_id.eq(person_id_join)),
392         ),
393       )
394       .select((
395         comment::all_columns,
396         Person::safe_columns_tuple(),
397         comment_alias_1::all_columns.nullable(),
398         PersonAlias1::safe_columns_tuple().nullable(),
399         post::all_columns,
400         Community::safe_columns_tuple(),
401         comment_aggregates::all_columns,
402         community_person_ban::all_columns.nullable(),
403         community_follower::all_columns.nullable(),
404         comment_saved::all_columns.nullable(),
405         person_block::all_columns.nullable(),
406         comment_like::score.nullable(),
407       ))
408       .into_boxed();
409
410     // The replies
411     if let Some(recipient_id) = self.recipient_id {
412       query = query
413         // TODO needs lots of testing
414         .filter(person_alias_1::id.eq(recipient_id)) // Gets the comment replies
415         .or_filter(
416           comment::parent_id
417             .is_null()
418             .and(post::creator_id.eq(recipient_id)),
419         ) // Gets the top level replies
420         .filter(comment::deleted.eq(false))
421         .filter(comment::removed.eq(false));
422     }
423
424     if self.unread_only.unwrap_or(false) {
425       query = query.filter(comment::read.eq(false));
426     }
427
428     if let Some(creator_id) = self.creator_id {
429       query = query.filter(comment::creator_id.eq(creator_id));
430     };
431
432     if let Some(community_id) = self.community_id {
433       query = query.filter(post::community_id.eq(community_id));
434     }
435
436     if let Some(community_actor_id) = self.community_actor_id {
437       query = query.filter(community::actor_id.eq(community_actor_id))
438     }
439
440     if let Some(post_id) = self.post_id {
441       query = query.filter(comment::post_id.eq(post_id));
442     };
443
444     if let Some(search_term) = self.search_term {
445       query = query.filter(comment::content.ilike(fuzzy_search(&search_term)));
446     };
447
448     if let Some(listing_type) = self.listing_type {
449       query = match listing_type {
450         ListingType::Subscribed => query.filter(community_follower::person_id.is_not_null()), // TODO could be this: and(community_follower::person_id.eq(person_id_join)),
451         ListingType::Local => query.filter(community::local.eq(true)),
452         _ => query,
453       };
454     }
455
456     if self.saved_only.unwrap_or(false) {
457       query = query.filter(comment_saved::id.is_not_null());
458     }
459
460     if !self.show_bot_accounts.unwrap_or(true) {
461       query = query.filter(person::bot_account.eq(false));
462     };
463
464     query = match self.sort.unwrap_or(SortType::New) {
465       SortType::Hot | SortType::Active => query
466         .order_by(hot_rank(comment_aggregates::score, comment_aggregates::published).desc())
467         .then_order_by(comment_aggregates::published.desc()),
468       SortType::New | SortType::MostComments | SortType::NewComments => {
469         query.order_by(comment::published.desc())
470       }
471       SortType::TopAll => query.order_by(comment_aggregates::score.desc()),
472       SortType::TopYear => query
473         .filter(comment::published.gt(now - 1.years()))
474         .order_by(comment_aggregates::score.desc()),
475       SortType::TopMonth => query
476         .filter(comment::published.gt(now - 1.months()))
477         .order_by(comment_aggregates::score.desc()),
478       SortType::TopWeek => query
479         .filter(comment::published.gt(now - 1.weeks()))
480         .order_by(comment_aggregates::score.desc()),
481       SortType::TopDay => query
482         .filter(comment::published.gt(now - 1.days()))
483         .order_by(comment_aggregates::score.desc()),
484     };
485
486     // Don't show blocked communities or persons
487     if self.my_person_id.is_some() {
488       query = query.filter(community_block::person_id.is_null());
489       query = query.filter(person_block::person_id.is_null());
490     }
491
492     let (limit, offset) = limit_and_offset(self.page, self.limit);
493
494     // Note: deleted and removed comments are done on the front side
495     let res = query
496       .limit(limit)
497       .offset(offset)
498       .load::<CommentViewTuple>(self.conn)?;
499
500     Ok(CommentView::from_tuple_to_vec(res))
501   }
502 }
503
504 impl ViewToVec for CommentView {
505   type DbTuple = CommentViewTuple;
506   fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
507     items
508       .iter()
509       .map(|a| Self {
510         comment: a.0.to_owned(),
511         creator: a.1.to_owned(),
512         recipient: a.3.to_owned(),
513         post: a.4.to_owned(),
514         community: a.5.to_owned(),
515         counts: a.6.to_owned(),
516         creator_banned_from_community: a.7.is_some(),
517         subscribed: a.8.is_some(),
518         saved: a.9.is_some(),
519         creator_blocked: a.10.is_some(),
520         my_vote: a.11,
521       })
522       .collect::<Vec<Self>>()
523   }
524 }
525
526 #[cfg(test)]
527 mod tests {
528   use crate::comment_view::*;
529   use lemmy_db_queries::{
530     aggregates::comment_aggregates::CommentAggregates,
531     establish_unpooled_connection,
532     Blockable,
533     Crud,
534     Likeable,
535   };
536   use lemmy_db_schema::source::{
537     comment::*,
538     community::*,
539     person::*,
540     person_block::PersonBlockForm,
541     post::*,
542   };
543   use serial_test::serial;
544
545   #[test]
546   #[serial]
547   fn test_crud() {
548     let conn = establish_unpooled_connection();
549
550     let new_person = PersonForm {
551       name: "timmy".into(),
552       ..PersonForm::default()
553     };
554
555     let inserted_person = Person::create(&conn, &new_person).unwrap();
556
557     let new_person_2 = PersonForm {
558       name: "sara".into(),
559       ..PersonForm::default()
560     };
561
562     let inserted_person_2 = Person::create(&conn, &new_person_2).unwrap();
563
564     let new_community = CommunityForm {
565       name: "test community 5".to_string(),
566       title: "nada".to_owned(),
567       ..CommunityForm::default()
568     };
569
570     let inserted_community = Community::create(&conn, &new_community).unwrap();
571
572     let new_post = PostForm {
573       name: "A test post 2".into(),
574       creator_id: inserted_person.id,
575       community_id: inserted_community.id,
576       ..PostForm::default()
577     };
578
579     let inserted_post = Post::create(&conn, &new_post).unwrap();
580
581     let comment_form = CommentForm {
582       content: "A test comment 32".into(),
583       creator_id: inserted_person.id,
584       post_id: inserted_post.id,
585       ..CommentForm::default()
586     };
587
588     let inserted_comment = Comment::create(&conn, &comment_form).unwrap();
589
590     let comment_form_2 = CommentForm {
591       content: "A test blocked comment".into(),
592       creator_id: inserted_person_2.id,
593       post_id: inserted_post.id,
594       parent_id: Some(inserted_comment.id),
595       ..CommentForm::default()
596     };
597
598     let inserted_comment_2 = Comment::create(&conn, &comment_form_2).unwrap();
599
600     let timmy_blocks_sara_form = PersonBlockForm {
601       person_id: inserted_person.id,
602       target_id: inserted_person_2.id,
603     };
604
605     let inserted_block = PersonBlock::block(&conn, &timmy_blocks_sara_form).unwrap();
606
607     let expected_block = PersonBlock {
608       id: inserted_block.id,
609       person_id: inserted_person.id,
610       target_id: inserted_person_2.id,
611       published: inserted_block.published,
612     };
613
614     assert_eq!(expected_block, inserted_block);
615
616     let comment_like_form = CommentLikeForm {
617       comment_id: inserted_comment.id,
618       post_id: inserted_post.id,
619       person_id: inserted_person.id,
620       score: 1,
621     };
622
623     let _inserted_comment_like = CommentLike::like(&conn, &comment_like_form).unwrap();
624
625     let agg = CommentAggregates::read(&conn, inserted_comment.id).unwrap();
626
627     let expected_comment_view_no_person = CommentView {
628       creator_banned_from_community: false,
629       my_vote: None,
630       subscribed: false,
631       saved: false,
632       creator_blocked: false,
633       comment: Comment {
634         id: inserted_comment.id,
635         content: "A test comment 32".into(),
636         creator_id: inserted_person.id,
637         post_id: inserted_post.id,
638         parent_id: None,
639         removed: false,
640         deleted: false,
641         read: false,
642         published: inserted_comment.published,
643         ap_id: inserted_comment.ap_id,
644         updated: None,
645         local: true,
646       },
647       creator: PersonSafe {
648         id: inserted_person.id,
649         name: "timmy".into(),
650         display_name: None,
651         published: inserted_person.published,
652         avatar: None,
653         actor_id: inserted_person.actor_id.to_owned(),
654         local: true,
655         banned: false,
656         deleted: false,
657         admin: false,
658         bot_account: false,
659         bio: None,
660         banner: None,
661         updated: None,
662         inbox_url: inserted_person.inbox_url.to_owned(),
663         shared_inbox_url: None,
664         matrix_user_id: None,
665       },
666       recipient: None,
667       post: Post {
668         id: inserted_post.id,
669         name: inserted_post.name.to_owned(),
670         creator_id: inserted_person.id,
671         url: None,
672         body: None,
673         published: inserted_post.published,
674         updated: None,
675         community_id: inserted_community.id,
676         removed: false,
677         deleted: false,
678         locked: false,
679         stickied: false,
680         nsfw: false,
681         embed_title: None,
682         embed_description: None,
683         embed_html: None,
684         thumbnail_url: None,
685         ap_id: inserted_post.ap_id.to_owned(),
686         local: true,
687       },
688       community: CommunitySafe {
689         id: inserted_community.id,
690         name: "test community 5".to_string(),
691         icon: None,
692         removed: false,
693         deleted: false,
694         nsfw: false,
695         actor_id: inserted_community.actor_id.to_owned(),
696         local: true,
697         title: "nada".to_owned(),
698         description: None,
699         updated: None,
700         banner: None,
701         published: inserted_community.published,
702       },
703       counts: CommentAggregates {
704         id: agg.id,
705         comment_id: inserted_comment.id,
706         score: 1,
707         upvotes: 1,
708         downvotes: 0,
709         published: agg.published,
710       },
711     };
712
713     let mut expected_comment_view_with_person = expected_comment_view_no_person.to_owned();
714     expected_comment_view_with_person.my_vote = Some(1);
715
716     let read_comment_views_no_person = CommentQueryBuilder::create(&conn)
717       .post_id(inserted_post.id)
718       .list()
719       .unwrap();
720
721     let read_comment_views_with_person = CommentQueryBuilder::create(&conn)
722       .post_id(inserted_post.id)
723       .my_person_id(inserted_person.id)
724       .list()
725       .unwrap();
726
727     let read_comment_from_blocked_person =
728       CommentView::read(&conn, inserted_comment_2.id, Some(inserted_person.id)).unwrap();
729
730     let like_removed = CommentLike::remove(&conn, inserted_person.id, inserted_comment.id).unwrap();
731     let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();
732     Comment::delete(&conn, inserted_comment_2.id).unwrap();
733     Post::delete(&conn, inserted_post.id).unwrap();
734     Community::delete(&conn, inserted_community.id).unwrap();
735     Person::delete(&conn, inserted_person.id).unwrap();
736     Person::delete(&conn, inserted_person_2.id).unwrap();
737
738     // Make sure its 1, not showing the blocked comment
739     assert_eq!(1, read_comment_views_with_person.len());
740
741     assert_eq!(
742       expected_comment_view_no_person,
743       read_comment_views_no_person[1]
744     );
745     assert_eq!(
746       expected_comment_view_with_person,
747       read_comment_views_with_person[0]
748     );
749
750     // Make sure block set the creator blocked
751     assert!(read_comment_from_blocked_person.creator_blocked);
752
753     assert_eq!(1, num_deleted);
754     assert_eq!(1, like_removed);
755   }
756 }