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