]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/inbox.tsx
Feature/user community block (#362)
[lemmy-ui.git] / src / shared / components / person / inbox.tsx
1 import { Component, linkEvent } from "inferno";
2 import {
3   BlockPersonResponse,
4   CommentResponse,
5   CommentView,
6   GetPersonMentions,
7   GetPersonMentionsResponse,
8   GetPrivateMessages,
9   GetReplies,
10   GetRepliesResponse,
11   PersonMentionResponse,
12   PersonMentionView,
13   PrivateMessageResponse,
14   PrivateMessagesResponse,
15   PrivateMessageView,
16   SiteView,
17   SortType,
18   UserOperation,
19 } from "lemmy-js-client";
20 import { Subscription } from "rxjs";
21 import { i18n } from "../../i18next";
22 import { InitialFetchRequest } from "../../interfaces";
23 import { UserService, WebSocketService } from "../../services";
24 import {
25   authField,
26   commentsToFlatNodes,
27   createCommentLikeRes,
28   editCommentRes,
29   fetchLimit,
30   isBrowser,
31   saveCommentRes,
32   setIsoData,
33   setupTippy,
34   toast,
35   updatePersonBlock,
36   wsClient,
37   wsJsonToRes,
38   wsSubscribe,
39   wsUserOp,
40 } from "../../utils";
41 import { CommentNodes } from "../comment/comment-nodes";
42 import { HtmlTags } from "../common/html-tags";
43 import { Icon, Spinner } from "../common/icon";
44 import { Paginator } from "../common/paginator";
45 import { SortSelect } from "../common/sort-select";
46 import { PrivateMessage } from "../private_message/private-message";
47
48 enum UnreadOrAll {
49   Unread,
50   All,
51 }
52
53 enum MessageType {
54   All,
55   Replies,
56   Mentions,
57   Messages,
58 }
59
60 enum ReplyEnum {
61   Reply,
62   Mention,
63   Message,
64 }
65 type ReplyType = {
66   id: number;
67   type_: ReplyEnum;
68   view: CommentView | PrivateMessageView | PersonMentionView;
69   published: string;
70 };
71
72 interface InboxState {
73   unreadOrAll: UnreadOrAll;
74   messageType: MessageType;
75   replies: CommentView[];
76   mentions: PersonMentionView[];
77   messages: PrivateMessageView[];
78   combined: ReplyType[];
79   sort: SortType;
80   page: number;
81   site_view: SiteView;
82   loading: boolean;
83 }
84
85 export class Inbox extends Component<any, InboxState> {
86   private isoData = setIsoData(this.context);
87   private subscription: Subscription;
88   private emptyState: InboxState = {
89     unreadOrAll: UnreadOrAll.Unread,
90     messageType: MessageType.All,
91     replies: [],
92     mentions: [],
93     messages: [],
94     combined: [],
95     sort: SortType.New,
96     page: 1,
97     site_view: this.isoData.site_res.site_view,
98     loading: true,
99   };
100
101   constructor(props: any, context: any) {
102     super(props, context);
103
104     this.state = this.emptyState;
105     this.handleSortChange = this.handleSortChange.bind(this);
106     this.handlePageChange = this.handlePageChange.bind(this);
107
108     if (!UserService.Instance.myUserInfo && isBrowser()) {
109       toast(i18n.t("not_logged_in"), "danger");
110       this.context.router.history.push(`/login`);
111     }
112
113     this.parseMessage = this.parseMessage.bind(this);
114     this.subscription = wsSubscribe(this.parseMessage);
115
116     // Only fetch the data if coming from another route
117     if (this.isoData.path == this.context.router.route.match.url) {
118       this.state.replies = this.isoData.routeData[0].replies || [];
119       this.state.mentions = this.isoData.routeData[1].mentions || [];
120       this.state.messages = this.isoData.routeData[2].messages || [];
121       this.state.combined = this.buildCombined();
122       this.state.loading = false;
123     } else {
124       this.refetch();
125     }
126   }
127
128   componentWillUnmount() {
129     if (isBrowser()) {
130       this.subscription.unsubscribe();
131     }
132   }
133
134   get documentTitle(): string {
135     return `@${
136       UserService.Instance.myUserInfo.local_user_view.person.name
137     } ${i18n.t("inbox")} - ${this.state.site_view.site.name}`;
138   }
139
140   render() {
141     return (
142       <div class="container">
143         {this.state.loading ? (
144           <h5>
145             <Spinner large />
146           </h5>
147         ) : (
148           <div class="row">
149             <div class="col-12">
150               <HtmlTags
151                 title={this.documentTitle}
152                 path={this.context.router.route.match.url}
153               />
154               <h5 class="mb-2">
155                 {i18n.t("inbox")}
156                 <small>
157                   <a
158                     href={`/feeds/inbox/${UserService.Instance.auth}.xml`}
159                     title="RSS"
160                     rel="noopener"
161                   >
162                     <Icon icon="rss" classes="ml-2 text-muted small" />
163                   </a>
164                 </small>
165               </h5>
166               {this.state.replies.length +
167                 this.state.mentions.length +
168                 this.state.messages.length >
169                 0 &&
170                 this.state.unreadOrAll == UnreadOrAll.Unread && (
171                   <button
172                     class="btn btn-secondary mb-2"
173                     onClick={linkEvent(this, this.markAllAsRead)}
174                   >
175                     {i18n.t("mark_all_as_read")}
176                   </button>
177                 )}
178               {this.selects()}
179               {this.state.messageType == MessageType.All && this.all()}
180               {this.state.messageType == MessageType.Replies && this.replies()}
181               {this.state.messageType == MessageType.Mentions &&
182                 this.mentions()}
183               {this.state.messageType == MessageType.Messages &&
184                 this.messages()}
185               <Paginator
186                 page={this.state.page}
187                 onChange={this.handlePageChange}
188               />
189             </div>
190           </div>
191         )}
192       </div>
193     );
194   }
195
196   unreadOrAllRadios() {
197     return (
198       <div class="btn-group btn-group-toggle flex-wrap mb-2">
199         <label
200           className={`btn btn-outline-secondary pointer
201             ${this.state.unreadOrAll == UnreadOrAll.Unread && "active"}
202           `}
203         >
204           <input
205             type="radio"
206             value={UnreadOrAll.Unread}
207             checked={this.state.unreadOrAll == UnreadOrAll.Unread}
208             onChange={linkEvent(this, this.handleUnreadOrAllChange)}
209           />
210           {i18n.t("unread")}
211         </label>
212         <label
213           className={`btn btn-outline-secondary pointer
214             ${this.state.unreadOrAll == UnreadOrAll.All && "active"}
215           `}
216         >
217           <input
218             type="radio"
219             value={UnreadOrAll.All}
220             checked={this.state.unreadOrAll == UnreadOrAll.All}
221             onChange={linkEvent(this, this.handleUnreadOrAllChange)}
222           />
223           {i18n.t("all")}
224         </label>
225       </div>
226     );
227   }
228
229   messageTypeRadios() {
230     return (
231       <div class="btn-group btn-group-toggle flex-wrap mb-2">
232         <label
233           className={`btn btn-outline-secondary pointer
234             ${this.state.messageType == MessageType.All && "active"}
235           `}
236         >
237           <input
238             type="radio"
239             value={MessageType.All}
240             checked={this.state.messageType == MessageType.All}
241             onChange={linkEvent(this, this.handleMessageTypeChange)}
242           />
243           {i18n.t("all")}
244         </label>
245         <label
246           className={`btn btn-outline-secondary pointer
247             ${this.state.messageType == MessageType.Replies && "active"}
248           `}
249         >
250           <input
251             type="radio"
252             value={MessageType.Replies}
253             checked={this.state.messageType == MessageType.Replies}
254             onChange={linkEvent(this, this.handleMessageTypeChange)}
255           />
256           {i18n.t("replies")}
257         </label>
258         <label
259           className={`btn btn-outline-secondary pointer
260             ${this.state.messageType == MessageType.Mentions && "active"}
261           `}
262         >
263           <input
264             type="radio"
265             value={MessageType.Mentions}
266             checked={this.state.messageType == MessageType.Mentions}
267             onChange={linkEvent(this, this.handleMessageTypeChange)}
268           />
269           {i18n.t("mentions")}
270         </label>
271         <label
272           className={`btn btn-outline-secondary pointer
273             ${this.state.messageType == MessageType.Messages && "active"}
274           `}
275         >
276           <input
277             type="radio"
278             value={MessageType.Messages}
279             checked={this.state.messageType == MessageType.Messages}
280             onChange={linkEvent(this, this.handleMessageTypeChange)}
281           />
282           {i18n.t("messages")}
283         </label>
284       </div>
285     );
286   }
287
288   selects() {
289     return (
290       <div className="mb-2">
291         <span class="mr-3">{this.unreadOrAllRadios()}</span>
292         <span class="mr-3">{this.messageTypeRadios()}</span>
293         <SortSelect
294           sort={this.state.sort}
295           onChange={this.handleSortChange}
296           hideHot
297           hideMostComments
298         />
299       </div>
300     );
301   }
302
303   replyToReplyType(r: CommentView): ReplyType {
304     return {
305       id: r.comment.id,
306       type_: ReplyEnum.Reply,
307       view: r,
308       published: r.comment.published,
309     };
310   }
311
312   mentionToReplyType(r: PersonMentionView): ReplyType {
313     return {
314       id: r.person_mention.id,
315       type_: ReplyEnum.Mention,
316       view: r,
317       published: r.comment.published,
318     };
319   }
320
321   messageToReplyType(r: PrivateMessageView): ReplyType {
322     return {
323       id: r.private_message.id,
324       type_: ReplyEnum.Message,
325       view: r,
326       published: r.private_message.published,
327     };
328   }
329
330   buildCombined(): ReplyType[] {
331     let replies: ReplyType[] = this.state.replies.map(r =>
332       this.replyToReplyType(r)
333     );
334     let mentions: ReplyType[] = this.state.mentions.map(r =>
335       this.mentionToReplyType(r)
336     );
337     let messages: ReplyType[] = this.state.messages.map(r =>
338       this.messageToReplyType(r)
339     );
340
341     return [...replies, ...mentions, ...messages].sort((a, b) =>
342       b.published.localeCompare(a.published)
343     );
344   }
345
346   renderReplyType(i: ReplyType) {
347     switch (i.type_) {
348       case ReplyEnum.Reply:
349         return (
350           <CommentNodes
351             key={i.id}
352             nodes={[{ comment_view: i.view as CommentView }]}
353             noIndent
354             markable
355             showCommunity
356             showContext
357             enableDownvotes={this.state.site_view.site.enable_downvotes}
358           />
359         );
360       case ReplyEnum.Mention:
361         return (
362           <CommentNodes
363             key={i.id}
364             nodes={[{ comment_view: i.view as PersonMentionView }]}
365             noIndent
366             markable
367             showCommunity
368             showContext
369             enableDownvotes={this.state.site_view.site.enable_downvotes}
370           />
371         );
372       case ReplyEnum.Message:
373         return (
374           <PrivateMessage
375             key={i.id}
376             private_message_view={i.view as PrivateMessageView}
377           />
378         );
379       default:
380         return <div />;
381     }
382   }
383
384   all() {
385     return <div>{this.state.combined.map(i => this.renderReplyType(i))}</div>;
386   }
387
388   replies() {
389     return (
390       <div>
391         <CommentNodes
392           nodes={commentsToFlatNodes(this.state.replies)}
393           noIndent
394           markable
395           showCommunity
396           showContext
397           enableDownvotes={this.state.site_view.site.enable_downvotes}
398         />
399       </div>
400     );
401   }
402
403   mentions() {
404     return (
405       <div>
406         {this.state.mentions.map(umv => (
407           <CommentNodes
408             key={umv.person_mention.id}
409             nodes={[{ comment_view: umv }]}
410             noIndent
411             markable
412             showCommunity
413             showContext
414             enableDownvotes={this.state.site_view.site.enable_downvotes}
415           />
416         ))}
417       </div>
418     );
419   }
420
421   messages() {
422     return (
423       <div>
424         {this.state.messages.map(pmv => (
425           <PrivateMessage
426             key={pmv.private_message.id}
427             private_message_view={pmv}
428           />
429         ))}
430       </div>
431     );
432   }
433
434   handlePageChange(page: number) {
435     this.setState({ page });
436     this.refetch();
437   }
438
439   handleUnreadOrAllChange(i: Inbox, event: any) {
440     i.state.unreadOrAll = Number(event.target.value);
441     i.state.page = 1;
442     i.setState(i.state);
443     i.refetch();
444   }
445
446   handleMessageTypeChange(i: Inbox, event: any) {
447     i.state.messageType = Number(event.target.value);
448     i.state.page = 1;
449     i.setState(i.state);
450     i.refetch();
451   }
452
453   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
454     let promises: Promise<any>[] = [];
455
456     // It can be /u/me, or /username/1
457     let repliesForm: GetReplies = {
458       sort: SortType.New,
459       unread_only: true,
460       page: 1,
461       limit: fetchLimit,
462       auth: req.auth,
463     };
464     promises.push(req.client.getReplies(repliesForm));
465
466     let personMentionsForm: GetPersonMentions = {
467       sort: SortType.New,
468       unread_only: true,
469       page: 1,
470       limit: fetchLimit,
471       auth: req.auth,
472     };
473     promises.push(req.client.getPersonMentions(personMentionsForm));
474
475     let privateMessagesForm: GetPrivateMessages = {
476       unread_only: true,
477       page: 1,
478       limit: fetchLimit,
479       auth: req.auth,
480     };
481     promises.push(req.client.getPrivateMessages(privateMessagesForm));
482
483     return promises;
484   }
485
486   refetch() {
487     let repliesForm: GetReplies = {
488       sort: this.state.sort,
489       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
490       page: this.state.page,
491       limit: fetchLimit,
492       auth: authField(),
493     };
494     WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
495
496     let personMentionsForm: GetPersonMentions = {
497       sort: this.state.sort,
498       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
499       page: this.state.page,
500       limit: fetchLimit,
501       auth: authField(),
502     };
503     WebSocketService.Instance.send(
504       wsClient.getPersonMentions(personMentionsForm)
505     );
506
507     let privateMessagesForm: GetPrivateMessages = {
508       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
509       page: this.state.page,
510       limit: fetchLimit,
511       auth: authField(),
512     };
513     WebSocketService.Instance.send(
514       wsClient.getPrivateMessages(privateMessagesForm)
515     );
516   }
517
518   handleSortChange(val: SortType) {
519     this.state.sort = val;
520     this.state.page = 1;
521     this.setState(this.state);
522     this.refetch();
523   }
524
525   markAllAsRead(i: Inbox) {
526     WebSocketService.Instance.send(
527       wsClient.markAllAsRead({
528         auth: authField(),
529       })
530     );
531     i.state.replies = [];
532     i.state.mentions = [];
533     i.state.messages = [];
534     i.sendUnreadCount();
535     window.scrollTo(0, 0);
536     i.setState(i.state);
537   }
538
539   parseMessage(msg: any) {
540     let op = wsUserOp(msg);
541     console.log(msg);
542     if (msg.error) {
543       toast(i18n.t(msg.error), "danger");
544       return;
545     } else if (msg.reconnect) {
546       this.refetch();
547     } else if (op == UserOperation.GetReplies) {
548       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
549       this.state.replies = data.replies;
550       this.state.combined = this.buildCombined();
551       this.state.loading = false;
552       this.sendUnreadCount();
553       window.scrollTo(0, 0);
554       this.setState(this.state);
555       setupTippy();
556     } else if (op == UserOperation.GetPersonMentions) {
557       let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data;
558       this.state.mentions = data.mentions;
559       this.state.combined = this.buildCombined();
560       this.sendUnreadCount();
561       window.scrollTo(0, 0);
562       this.setState(this.state);
563       setupTippy();
564     } else if (op == UserOperation.GetPrivateMessages) {
565       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
566       this.state.messages = data.private_messages;
567       this.state.combined = this.buildCombined();
568       this.sendUnreadCount();
569       window.scrollTo(0, 0);
570       this.setState(this.state);
571       setupTippy();
572     } else if (op == UserOperation.EditPrivateMessage) {
573       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
574       let found: PrivateMessageView = this.state.messages.find(
575         m =>
576           m.private_message.id === data.private_message_view.private_message.id
577       );
578       if (found) {
579         let combinedView = this.state.combined.find(
580           i => i.id == data.private_message_view.private_message.id
581         ).view as PrivateMessageView;
582         found.private_message.content = combinedView.private_message.content =
583           data.private_message_view.private_message.content;
584         found.private_message.updated = combinedView.private_message.updated =
585           data.private_message_view.private_message.updated;
586       }
587       this.setState(this.state);
588     } else if (op == UserOperation.DeletePrivateMessage) {
589       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
590       let found: PrivateMessageView = this.state.messages.find(
591         m =>
592           m.private_message.id === data.private_message_view.private_message.id
593       );
594       if (found) {
595         let combinedView = this.state.combined.find(
596           i => i.id == data.private_message_view.private_message.id
597         ).view as PrivateMessageView;
598         found.private_message.deleted = combinedView.private_message.deleted =
599           data.private_message_view.private_message.deleted;
600         found.private_message.updated = combinedView.private_message.updated =
601           data.private_message_view.private_message.updated;
602       }
603       this.setState(this.state);
604     } else if (op == UserOperation.MarkPrivateMessageAsRead) {
605       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
606       let found: PrivateMessageView = this.state.messages.find(
607         m =>
608           m.private_message.id === data.private_message_view.private_message.id
609       );
610
611       if (found) {
612         let combinedView = this.state.combined.find(
613           i => i.id == data.private_message_view.private_message.id
614         ).view as PrivateMessageView;
615         found.private_message.updated = combinedView.private_message.updated =
616           data.private_message_view.private_message.updated;
617
618         // If youre in the unread view, just remove it from the list
619         if (
620           this.state.unreadOrAll == UnreadOrAll.Unread &&
621           data.private_message_view.private_message.read
622         ) {
623           this.state.messages = this.state.messages.filter(
624             r =>
625               r.private_message.id !==
626               data.private_message_view.private_message.id
627           );
628           this.state.combined = this.state.combined.filter(
629             r => r.id !== data.private_message_view.private_message.id
630           );
631         } else {
632           found.private_message.read = combinedView.private_message.read =
633             data.private_message_view.private_message.read;
634         }
635       }
636       this.sendUnreadCount();
637       this.setState(this.state);
638     } else if (op == UserOperation.MarkAllAsRead) {
639       // Moved to be instant
640     } else if (
641       op == UserOperation.EditComment ||
642       op == UserOperation.DeleteComment ||
643       op == UserOperation.RemoveComment
644     ) {
645       let data = wsJsonToRes<CommentResponse>(msg).data;
646       editCommentRes(data.comment_view, this.state.replies);
647       this.setState(this.state);
648     } else if (op == UserOperation.MarkCommentAsRead) {
649       let data = wsJsonToRes<CommentResponse>(msg).data;
650
651       // If youre in the unread view, just remove it from the list
652       if (
653         this.state.unreadOrAll == UnreadOrAll.Unread &&
654         data.comment_view.comment.read
655       ) {
656         this.state.replies = this.state.replies.filter(
657           r => r.comment.id !== data.comment_view.comment.id
658         );
659         this.state.combined = this.state.combined.filter(
660           r => r.id !== data.comment_view.comment.id
661         );
662       } else {
663         let found = this.state.replies.find(
664           c => c.comment.id == data.comment_view.comment.id
665         );
666         let combinedView = this.state.combined.find(
667           i => i.id == data.comment_view.comment.id
668         ).view as CommentView;
669         found.comment.read = combinedView.comment.read =
670           data.comment_view.comment.read;
671       }
672       this.sendUnreadCount();
673       this.setState(this.state);
674       setupTippy();
675     } else if (op == UserOperation.MarkPersonMentionAsRead) {
676       let data = wsJsonToRes<PersonMentionResponse>(msg).data;
677
678       // TODO this might not be correct, it might need to use the comment id
679       let found = this.state.mentions.find(
680         c => c.person_mention.id == data.person_mention_view.person_mention.id
681       );
682
683       if (found) {
684         let combinedView = this.state.combined.find(
685           i => i.id == data.person_mention_view.person_mention.id
686         ).view as PersonMentionView;
687         found.comment.content = combinedView.comment.content =
688           data.person_mention_view.comment.content;
689         found.comment.updated = combinedView.comment.updated =
690           data.person_mention_view.comment.updated;
691         found.comment.removed = combinedView.comment.removed =
692           data.person_mention_view.comment.removed;
693         found.comment.deleted = combinedView.comment.deleted =
694           data.person_mention_view.comment.deleted;
695         found.counts.upvotes = combinedView.counts.upvotes =
696           data.person_mention_view.counts.upvotes;
697         found.counts.downvotes = combinedView.counts.downvotes =
698           data.person_mention_view.counts.downvotes;
699         found.counts.score = combinedView.counts.score =
700           data.person_mention_view.counts.score;
701
702         // If youre in the unread view, just remove it from the list
703         if (
704           this.state.unreadOrAll == UnreadOrAll.Unread &&
705           data.person_mention_view.person_mention.read
706         ) {
707           this.state.mentions = this.state.mentions.filter(
708             r =>
709               r.person_mention.id !== data.person_mention_view.person_mention.id
710           );
711           this.state.combined = this.state.combined.filter(
712             r => r.id !== data.person_mention_view.person_mention.id
713           );
714         } else {
715           // TODO test to make sure these mentions are getting marked as read
716           found.person_mention.read = combinedView.person_mention.read =
717             data.person_mention_view.person_mention.read;
718         }
719       }
720       this.sendUnreadCount();
721       this.setState(this.state);
722     } else if (op == UserOperation.CreateComment) {
723       let data = wsJsonToRes<CommentResponse>(msg).data;
724
725       if (
726         data.recipient_ids.includes(
727           UserService.Instance.myUserInfo.local_user_view.local_user.id
728         )
729       ) {
730         this.state.replies.unshift(data.comment_view);
731         this.state.combined.unshift(this.replyToReplyType(data.comment_view));
732         this.setState(this.state);
733       } else if (
734         data.comment_view.creator.id ==
735         UserService.Instance.myUserInfo.local_user_view.person.id
736       ) {
737         // TODO this seems wrong, you should be using form_id
738         toast(i18n.t("reply_sent"));
739       }
740     } else if (op == UserOperation.CreatePrivateMessage) {
741       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
742       if (
743         data.private_message_view.recipient.id ==
744         UserService.Instance.myUserInfo.local_user_view.person.id
745       ) {
746         this.state.messages.unshift(data.private_message_view);
747         this.state.combined.unshift(
748           this.messageToReplyType(data.private_message_view)
749         );
750         this.setState(this.state);
751       }
752     } else if (op == UserOperation.SaveComment) {
753       let data = wsJsonToRes<CommentResponse>(msg).data;
754       saveCommentRes(data.comment_view, this.state.replies);
755       this.setState(this.state);
756       setupTippy();
757     } else if (op == UserOperation.CreateCommentLike) {
758       let data = wsJsonToRes<CommentResponse>(msg).data;
759       createCommentLikeRes(data.comment_view, this.state.replies);
760       this.setState(this.state);
761     } else if (op == UserOperation.BlockPerson) {
762       let data = wsJsonToRes<BlockPersonResponse>(msg).data;
763       updatePersonBlock(data);
764     }
765   }
766
767   sendUnreadCount() {
768     UserService.Instance.unreadCountSub.next(this.unreadCount());
769   }
770
771   unreadCount(): number {
772     return (
773       this.state.replies.filter(r => !r.comment.read).length +
774       this.state.mentions.filter(r => !r.person_mention.read).length +
775       this.state.messages.filter(
776         r =>
777           UserService.Instance.myUserInfo &&
778           !r.private_message.read &&
779           // TODO also seems very strange and wrong
780           r.creator.id !==
781             UserService.Instance.myUserInfo.local_user_view.person.id
782       ).length
783     );
784   }
785 }