]> Untitled Git - lemmy-ui.git/blob - src/shared/components/inbox.tsx
Merge remote-tracking branch 'origin/drone-ci' into drone-ci-dess
[lemmy-ui.git] / src / shared / components / inbox.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Subscription } from 'rxjs';
3 import {
4   UserOperation,
5   CommentView,
6   SortType,
7   GetReplies,
8   GetRepliesResponse,
9   GetUserMentions,
10   GetUserMentionsResponse,
11   UserMentionResponse,
12   CommentResponse,
13   PrivateMessageView,
14   GetPrivateMessages,
15   PrivateMessagesResponse,
16   PrivateMessageResponse,
17   SiteView,
18   UserMentionView,
19 } from 'lemmy-js-client';
20 import { WebSocketService, UserService } from '../services';
21 import {
22   wsJsonToRes,
23   fetchLimit,
24   toast,
25   editCommentRes,
26   saveCommentRes,
27   createCommentLikeRes,
28   commentsToFlatNodes,
29   setupTippy,
30   setIsoData,
31   wsSubscribe,
32   isBrowser,
33   wsUserOp,
34   wsClient,
35   authField,
36 } from '../utils';
37 import { CommentNodes } from './comment-nodes';
38 import { PrivateMessage } from './private-message';
39 import { HtmlTags } from './html-tags';
40 import { SortSelect } from './sort-select';
41 import { i18n } from '../i18next';
42 import { InitialFetchRequest } from 'shared/interfaces';
43
44 enum UnreadOrAll {
45   Unread,
46   All,
47 }
48
49 enum MessageType {
50   All,
51   Replies,
52   Mentions,
53   Messages,
54 }
55
56 enum ReplyEnum {
57   Reply,
58   Mention,
59   Message,
60 }
61 type ReplyType = {
62   id: number;
63   type_: ReplyEnum;
64   view: CommentView | PrivateMessageView | UserMentionView;
65   published: string;
66 };
67
68 interface InboxState {
69   unreadOrAll: UnreadOrAll;
70   messageType: MessageType;
71   replies: CommentView[];
72   mentions: UserMentionView[];
73   messages: PrivateMessageView[];
74   sort: SortType;
75   page: number;
76   site_view: SiteView;
77   loading: boolean;
78 }
79
80 export class Inbox extends Component<any, InboxState> {
81   private isoData = setIsoData(this.context);
82   private subscription: Subscription;
83   private emptyState: InboxState = {
84     unreadOrAll: UnreadOrAll.Unread,
85     messageType: MessageType.All,
86     replies: [],
87     mentions: [],
88     messages: [],
89     sort: SortType.New,
90     page: 1,
91     site_view: this.isoData.site_res.site_view,
92     loading: true,
93   };
94
95   constructor(props: any, context: any) {
96     super(props, context);
97
98     this.state = this.emptyState;
99     this.handleSortChange = this.handleSortChange.bind(this);
100
101     if (!UserService.Instance.user && isBrowser()) {
102       toast(i18n.t('not_logged_in'), 'danger');
103       this.context.router.history.push(`/login`);
104     }
105
106     this.parseMessage = this.parseMessage.bind(this);
107     this.subscription = wsSubscribe(this.parseMessage);
108
109     // Only fetch the data if coming from another route
110     if (this.isoData.path == this.context.router.route.match.url) {
111       this.state.replies = this.isoData.routeData[0].replies || [];
112       this.state.mentions = this.isoData.routeData[1].mentions || [];
113       this.state.messages = this.isoData.routeData[2].messages || [];
114       this.state.loading = false;
115     } else {
116       this.refetch();
117     }
118   }
119
120   componentWillUnmount() {
121     if (isBrowser()) {
122       this.subscription.unsubscribe();
123     }
124   }
125
126   get documentTitle(): string {
127     return `@${UserService.Instance.user.name} ${i18n.t('inbox')} - ${
128       this.state.site_view.site.name
129     }`;
130   }
131
132   render() {
133     return (
134       <div class="container">
135         {this.state.loading ? (
136           <h5>
137             <svg class="icon icon-spinner spin">
138               <use xlinkHref="#icon-spinner"></use>
139             </svg>
140           </h5>
141         ) : (
142           <div class="row">
143             <div class="col-12">
144               <HtmlTags
145                 title={this.documentTitle}
146                 path={this.context.router.route.match.url}
147               />
148               <h5 class="mb-1">
149                 {i18n.t('inbox')}
150                 <small>
151                   <a
152                     href={`/feeds/inbox/${UserService.Instance.auth}.xml`}
153                     target="_blank"
154                     title="RSS"
155                     rel="noopener"
156                   >
157                     <svg class="icon ml-2 text-muted small">
158                       <use xlinkHref="#icon-rss">#</use>
159                     </svg>
160                   </a>
161                 </small>
162               </h5>
163               {this.state.replies.length +
164                 this.state.mentions.length +
165                 this.state.messages.length >
166                 0 &&
167                 this.state.unreadOrAll == UnreadOrAll.Unread && (
168                   <ul class="list-inline mb-1 text-muted small font-weight-bold">
169                     <li className="list-inline-item">
170                       <span
171                         class="pointer"
172                         onClick={linkEvent(this, this.markAllAsRead)}
173                       >
174                         {i18n.t('mark_all_as_read')}
175                       </span>
176                     </li>
177                   </ul>
178                 )}
179               {this.selects()}
180               {this.state.messageType == MessageType.All && this.all()}
181               {this.state.messageType == MessageType.Replies && this.replies()}
182               {this.state.messageType == MessageType.Mentions &&
183                 this.mentions()}
184               {this.state.messageType == MessageType.Messages &&
185                 this.messages()}
186               {this.paginator()}
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         />
296       </div>
297     );
298   }
299
300   combined(): ReplyType[] {
301     let id = 0;
302     let replies: ReplyType[] = this.state.replies.map(r => ({
303       id: id++,
304       type_: ReplyEnum.Reply,
305       view: r,
306       published: r.comment.published,
307     }));
308     let mentions: ReplyType[] = this.state.mentions.map(r => ({
309       id: id++,
310       type_: ReplyEnum.Mention,
311       view: r,
312       published: r.comment.published,
313     }));
314     let messages: ReplyType[] = this.state.messages.map(r => ({
315       id: id++,
316       type_: ReplyEnum.Message,
317       view: r,
318       published: r.private_message.published,
319     }));
320
321     return [...replies, ...mentions, ...messages].sort((a, b) =>
322       b.published.localeCompare(a.published)
323     );
324   }
325
326   renderReplyType(i: ReplyType) {
327     switch (i.type_) {
328       case ReplyEnum.Reply:
329         return (
330           <CommentNodes
331             key={i.id}
332             nodes={[{ comment_view: i.view as CommentView }]}
333             noIndent
334             markable
335             showCommunity
336             showContext
337             enableDownvotes={this.state.site_view.site.enable_downvotes}
338           />
339         );
340       case ReplyEnum.Mention:
341         return (
342           <CommentNodes
343             key={i.id}
344             nodes={[{ comment_view: i.view as UserMentionView }]}
345             noIndent
346             markable
347             showCommunity
348             showContext
349             enableDownvotes={this.state.site_view.site.enable_downvotes}
350           />
351         );
352       case ReplyEnum.Message:
353         return (
354           <PrivateMessage
355             key={i.id}
356             private_message_view={i.view as PrivateMessageView}
357           />
358         );
359       default:
360         return <div />;
361     }
362   }
363
364   all() {
365     return <div>{this.combined().map(i => this.renderReplyType(i))}</div>;
366   }
367
368   replies() {
369     return (
370       <div>
371         <CommentNodes
372           nodes={commentsToFlatNodes(this.state.replies)}
373           noIndent
374           markable
375           showCommunity
376           showContext
377           enableDownvotes={this.state.site_view.site.enable_downvotes}
378         />
379       </div>
380     );
381   }
382
383   mentions() {
384     return (
385       <div>
386         {this.state.mentions.map(umv => (
387           <CommentNodes
388             key={umv.user_mention.id}
389             nodes={[{ comment_view: umv }]}
390             noIndent
391             markable
392             showCommunity
393             showContext
394             enableDownvotes={this.state.site_view.site.enable_downvotes}
395           />
396         ))}
397       </div>
398     );
399   }
400
401   messages() {
402     return (
403       <div>
404         {this.state.messages.map(pmv => (
405           <PrivateMessage
406             key={pmv.private_message.id}
407             private_message_view={pmv}
408           />
409         ))}
410       </div>
411     );
412   }
413
414   paginator() {
415     return (
416       <div class="mt-2">
417         {this.state.page > 1 && (
418           <button
419             class="btn btn-secondary mr-1"
420             onClick={linkEvent(this, this.prevPage)}
421           >
422             {i18n.t('prev')}
423           </button>
424         )}
425         {this.unreadCount() > 0 && (
426           <button
427             class="btn btn-secondary"
428             onClick={linkEvent(this, this.nextPage)}
429           >
430             {i18n.t('next')}
431           </button>
432         )}
433       </div>
434     );
435   }
436
437   nextPage(i: Inbox) {
438     i.state.page++;
439     i.setState(i.state);
440     i.refetch();
441   }
442
443   prevPage(i: Inbox) {
444     i.state.page--;
445     i.setState(i.state);
446     i.refetch();
447   }
448
449   handleUnreadOrAllChange(i: Inbox, event: any) {
450     i.state.unreadOrAll = Number(event.target.value);
451     i.state.page = 1;
452     i.setState(i.state);
453     i.refetch();
454   }
455
456   handleMessageTypeChange(i: Inbox, event: any) {
457     i.state.messageType = Number(event.target.value);
458     i.state.page = 1;
459     i.setState(i.state);
460     i.refetch();
461   }
462
463   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
464     let promises: Promise<any>[] = [];
465
466     // It can be /u/me, or /username/1
467     let repliesForm: GetReplies = {
468       sort: SortType.New,
469       unread_only: true,
470       page: 1,
471       limit: fetchLimit,
472       auth: req.auth,
473     };
474     promises.push(req.client.getReplies(repliesForm));
475
476     let userMentionsForm: GetUserMentions = {
477       sort: SortType.New,
478       unread_only: true,
479       page: 1,
480       limit: fetchLimit,
481       auth: req.auth,
482     };
483     promises.push(req.client.getUserMentions(userMentionsForm));
484
485     let privateMessagesForm: GetPrivateMessages = {
486       unread_only: true,
487       page: 1,
488       limit: fetchLimit,
489       auth: req.auth,
490     };
491     promises.push(req.client.getPrivateMessages(privateMessagesForm));
492
493     return promises;
494   }
495
496   refetch() {
497     let repliesForm: GetReplies = {
498       sort: this.state.sort,
499       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
500       page: this.state.page,
501       limit: fetchLimit,
502       auth: authField(),
503     };
504     WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
505
506     let userMentionsForm: GetUserMentions = {
507       sort: this.state.sort,
508       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
509       page: this.state.page,
510       limit: fetchLimit,
511       auth: authField(),
512     };
513     WebSocketService.Instance.send(wsClient.getUserMentions(userMentionsForm));
514
515     let privateMessagesForm: GetPrivateMessages = {
516       unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
517       page: this.state.page,
518       limit: fetchLimit,
519       auth: authField(),
520     };
521     WebSocketService.Instance.send(
522       wsClient.getPrivateMessages(privateMessagesForm)
523     );
524   }
525
526   handleSortChange(val: SortType) {
527     this.state.sort = val;
528     this.state.page = 1;
529     this.setState(this.state);
530     this.refetch();
531   }
532
533   markAllAsRead(i: Inbox) {
534     WebSocketService.Instance.send(
535       wsClient.markAllAsRead({
536         auth: authField(),
537       })
538     );
539     i.state.replies = [];
540     i.state.mentions = [];
541     i.state.messages = [];
542     i.sendUnreadCount();
543     window.scrollTo(0, 0);
544     i.setState(i.state);
545   }
546
547   parseMessage(msg: any) {
548     let op = wsUserOp(msg);
549     if (msg.error) {
550       toast(i18n.t(msg.error), 'danger');
551       return;
552     } else if (msg.reconnect) {
553       this.refetch();
554     } else if (op == UserOperation.GetReplies) {
555       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
556       this.state.replies = data.replies;
557       this.state.loading = false;
558       this.sendUnreadCount();
559       window.scrollTo(0, 0);
560       this.setState(this.state);
561       setupTippy();
562     } else if (op == UserOperation.GetUserMentions) {
563       let data = wsJsonToRes<GetUserMentionsResponse>(msg).data;
564       this.state.mentions = data.mentions;
565       this.sendUnreadCount();
566       window.scrollTo(0, 0);
567       this.setState(this.state);
568       setupTippy();
569     } else if (op == UserOperation.GetPrivateMessages) {
570       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
571       this.state.messages = data.private_messages;
572       this.sendUnreadCount();
573       window.scrollTo(0, 0);
574       this.setState(this.state);
575       setupTippy();
576     } else if (op == UserOperation.EditPrivateMessage) {
577       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
578       let found: PrivateMessageView = this.state.messages.find(
579         m =>
580           m.private_message.id === data.private_message_view.private_message.id
581       );
582       if (found) {
583         found.private_message.content =
584           data.private_message_view.private_message.content;
585         found.private_message.updated =
586           data.private_message_view.private_message.updated;
587       }
588       this.setState(this.state);
589     } else if (op == UserOperation.DeletePrivateMessage) {
590       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
591       let found: PrivateMessageView = this.state.messages.find(
592         m =>
593           m.private_message.id === data.private_message_view.private_message.id
594       );
595       if (found) {
596         found.private_message.deleted =
597           data.private_message_view.private_message.deleted;
598         found.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         found.private_message.updated =
611           data.private_message_view.private_message.updated;
612
613         // If youre in the unread view, just remove it from the list
614         if (
615           this.state.unreadOrAll == UnreadOrAll.Unread &&
616           data.private_message_view.private_message.read
617         ) {
618           this.state.messages = this.state.messages.filter(
619             r =>
620               r.private_message.id !==
621               data.private_message_view.private_message.id
622           );
623         } else {
624           let found = this.state.messages.find(
625             c =>
626               c.private_message.id ==
627               data.private_message_view.private_message.id
628           );
629           found.private_message.read =
630             data.private_message_view.private_message.read;
631         }
632       }
633       this.sendUnreadCount();
634       this.setState(this.state);
635     } else if (op == UserOperation.MarkAllAsRead) {
636       // Moved to be instant
637     } else if (
638       op == UserOperation.EditComment ||
639       op == UserOperation.DeleteComment ||
640       op == UserOperation.RemoveComment
641     ) {
642       let data = wsJsonToRes<CommentResponse>(msg).data;
643       editCommentRes(data.comment_view, this.state.replies);
644       this.setState(this.state);
645     } else if (op == UserOperation.MarkCommentAsRead) {
646       let data = wsJsonToRes<CommentResponse>(msg).data;
647
648       // If youre in the unread view, just remove it from the list
649       if (
650         this.state.unreadOrAll == UnreadOrAll.Unread &&
651         data.comment_view.comment.read
652       ) {
653         this.state.replies = this.state.replies.filter(
654           r => r.comment.id !== data.comment_view.comment.id
655         );
656       } else {
657         let found = this.state.replies.find(
658           c => c.comment.id == data.comment_view.comment.id
659         );
660         found.comment.read = data.comment_view.comment.read;
661       }
662       this.sendUnreadCount();
663       this.setState(this.state);
664       setupTippy();
665     } else if (op == UserOperation.MarkUserMentionAsRead) {
666       let data = wsJsonToRes<UserMentionResponse>(msg).data;
667
668       // TODO this might not be correct, it might need to use the comment id
669       let found = this.state.mentions.find(
670         c => c.user_mention.id == data.user_mention_view.user_mention.id
671       );
672       found.comment.content = data.user_mention_view.comment.content;
673       found.comment.updated = data.user_mention_view.comment.updated;
674       found.comment.removed = data.user_mention_view.comment.removed;
675       found.comment.deleted = data.user_mention_view.comment.deleted;
676       found.counts.upvotes = data.user_mention_view.counts.upvotes;
677       found.counts.downvotes = data.user_mention_view.counts.downvotes;
678       found.counts.score = data.user_mention_view.counts.score;
679
680       // If youre in the unread view, just remove it from the list
681       if (
682         this.state.unreadOrAll == UnreadOrAll.Unread &&
683         data.user_mention_view.user_mention.read
684       ) {
685         this.state.mentions = this.state.mentions.filter(
686           r => r.user_mention.id !== data.user_mention_view.user_mention.id
687         );
688       } else {
689         let found = this.state.mentions.find(
690           c => c.user_mention.id == data.user_mention_view.user_mention.id
691         );
692         // TODO test to make sure these mentions are getting marked as read
693         found.user_mention.read = data.user_mention_view.user_mention.read;
694       }
695       this.sendUnreadCount();
696       this.setState(this.state);
697     } else if (op == UserOperation.CreateComment) {
698       let data = wsJsonToRes<CommentResponse>(msg).data;
699
700       if (data.recipient_ids.includes(UserService.Instance.user.id)) {
701         this.state.replies.unshift(data.comment_view);
702         this.setState(this.state);
703       } else if (data.comment_view.creator.id == UserService.Instance.user.id) {
704         // TODO this seems wrong, you should be using form_id
705         toast(i18n.t('reply_sent'));
706       }
707     } else if (op == UserOperation.CreatePrivateMessage) {
708       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
709       if (
710         data.private_message_view.recipient.id == UserService.Instance.user.id
711       ) {
712         this.state.messages.unshift(data.private_message_view);
713         this.setState(this.state);
714       }
715     } else if (op == UserOperation.SaveComment) {
716       let data = wsJsonToRes<CommentResponse>(msg).data;
717       saveCommentRes(data.comment_view, this.state.replies);
718       this.setState(this.state);
719       setupTippy();
720     } else if (op == UserOperation.CreateCommentLike) {
721       let data = wsJsonToRes<CommentResponse>(msg).data;
722       createCommentLikeRes(data.comment_view, this.state.replies);
723       this.setState(this.state);
724     }
725   }
726
727   sendUnreadCount() {
728     UserService.Instance.unreadCountSub.next(this.unreadCount());
729   }
730
731   unreadCount(): number {
732     return (
733       this.state.replies.filter(r => !r.comment.read).length +
734       this.state.mentions.filter(r => !r.user_mention.read).length +
735       this.state.messages.filter(
736         r =>
737           UserService.Instance.user &&
738           !r.private_message.read &&
739           // TODO also seems very strang and wrong
740           r.creator.id !== UserService.Instance.user.id
741       ).length
742     );
743   }
744 }