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