]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Reporting (#434)
[lemmy-ui.git] / src / shared / components / app / navbar.tsx
1 import { Component, createRef, linkEvent, RefObject } from "inferno";
2 import { Link } from "inferno-router";
3 import {
4   CommentResponse,
5   CommentView,
6   GetPersonMentions,
7   GetPersonMentionsResponse,
8   GetPrivateMessages,
9   GetReplies,
10   GetRepliesResponse,
11   GetReportCount,
12   GetReportCountResponse,
13   GetSiteResponse,
14   PrivateMessageResponse,
15   PrivateMessagesResponse,
16   PrivateMessageView,
17   SortType,
18   UserOperation,
19 } from "lemmy-js-client";
20 import { Subscription } from "rxjs";
21 import { i18n } from "../../i18next";
22 import { UserService, WebSocketService } from "../../services";
23 import {
24   authField,
25   donateLemmyUrl,
26   fetchLimit,
27   getLanguage,
28   isBrowser,
29   notifyComment,
30   notifyPrivateMessage,
31   numToSI,
32   setTheme,
33   showAvatars,
34   toast,
35   wsClient,
36   wsJsonToRes,
37   wsSubscribe,
38   wsUserOp,
39 } from "../../utils";
40 import { Icon } from "../common/icon";
41 import { PictrsImage } from "../common/pictrs-image";
42
43 interface NavbarProps {
44   site_res: GetSiteResponse;
45 }
46
47 interface NavbarState {
48   isLoggedIn: boolean;
49   expanded: boolean;
50   replies: CommentView[];
51   mentions: CommentView[];
52   messages: PrivateMessageView[];
53   unreadInboxCount: number;
54   unreadReportCount: number;
55   searchParam: string;
56   toggleSearch: boolean;
57   showDropdown: boolean;
58   onSiteBanner?(url: string): any;
59 }
60
61 export class Navbar extends Component<NavbarProps, NavbarState> {
62   private wsSub: Subscription;
63   private userSub: Subscription;
64   private unreadInboxCountSub: Subscription;
65   private unreadReportCountSub: Subscription;
66   private searchTextField: RefObject<HTMLInputElement>;
67   emptyState: NavbarState = {
68     isLoggedIn: !!this.props.site_res.my_user,
69     unreadInboxCount: 0,
70     unreadReportCount: 0,
71     replies: [],
72     mentions: [],
73     messages: [],
74     expanded: false,
75     searchParam: "",
76     toggleSearch: false,
77     showDropdown: false,
78   };
79   subscription: any;
80
81   constructor(props: any, context: any) {
82     super(props, context);
83     this.state = this.emptyState;
84
85     this.parseMessage = this.parseMessage.bind(this);
86     this.subscription = wsSubscribe(this.parseMessage);
87   }
88
89   componentDidMount() {
90     // Subscribe to jwt changes
91     if (isBrowser()) {
92       this.websocketEvents();
93
94       this.searchTextField = createRef();
95       console.log(`isLoggedIn = ${this.state.isLoggedIn}`);
96
97       // On the first load, check the unreads
98       if (this.state.isLoggedIn == false) {
99         // setTheme(data.my_user.theme, true);
100         // i18n.changeLanguage(getLanguage());
101         // i18n.changeLanguage('de');
102       } else {
103         this.requestNotificationPermission();
104         WebSocketService.Instance.send(
105           wsClient.userJoin({
106             auth: authField(),
107           })
108         );
109         this.fetchUnreads();
110       }
111
112       this.userSub = UserService.Instance.jwtSub.subscribe(res => {
113         // A login
114         if (res !== undefined) {
115           this.requestNotificationPermission();
116           WebSocketService.Instance.send(
117             wsClient.getSite({ auth: authField() })
118           );
119         } else {
120           this.setState({ isLoggedIn: false });
121         }
122       });
123
124       // Subscribe to unread count changes
125       this.unreadInboxCountSub =
126         UserService.Instance.unreadInboxCountSub.subscribe(res => {
127           this.setState({ unreadInboxCount: res });
128         });
129       // Subscribe to unread report count changes
130       this.unreadReportCountSub =
131         UserService.Instance.unreadReportCountSub.subscribe(res => {
132           this.setState({ unreadReportCount: res });
133         });
134     }
135   }
136
137   componentWillUnmount() {
138     this.wsSub.unsubscribe();
139     this.userSub.unsubscribe();
140     this.unreadInboxCountSub.unsubscribe();
141     this.unreadReportCountSub.unsubscribe();
142   }
143
144   updateUrl() {
145     const searchParam = this.state.searchParam;
146     this.setState({ searchParam: "" });
147     this.setState({ toggleSearch: false });
148     this.setState({ showDropdown: false, expanded: false });
149     if (searchParam === "") {
150       this.context.router.history.push(`/search/`);
151     } else {
152       const searchParamEncoded = encodeURIComponent(searchParam);
153       this.context.router.history.push(
154         `/search/q/${searchParamEncoded}/type/All/sort/TopAll/listing_type/All/community_id/0/creator_id/0/page/1`
155       );
156     }
157   }
158
159   render() {
160     return this.navbar();
161   }
162
163   // TODO class active corresponding to current page
164   navbar() {
165     let myUserInfo =
166       UserService.Instance.myUserInfo || this.props.site_res.my_user;
167     let person = myUserInfo?.local_user_view.person;
168     return (
169       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
170         <div class="container">
171           {this.props.site_res.site_view && (
172             <button
173               title={
174                 this.props.site_res.site_view.site.description ||
175                 this.props.site_res.site_view.site.name
176               }
177               className="d-flex align-items-center navbar-brand mr-md-3 btn btn-link"
178               onClick={linkEvent(this, this.handleGotoHome)}
179             >
180               {this.props.site_res.site_view.site.icon && showAvatars() && (
181                 <PictrsImage
182                   src={this.props.site_res.site_view.site.icon}
183                   icon
184                 />
185               )}
186               {this.props.site_res.site_view.site.name}
187             </button>
188           )}
189           {this.state.isLoggedIn && (
190             <>
191               <ul class="navbar-nav ml-auto">
192                 <li className="nav-item">
193                   <button
194                     className="p-1 navbar-toggler nav-link border-0 btn btn-link"
195                     onClick={linkEvent(this, this.handleGotoInbox)}
196                     title={i18n.t("unread_messages", {
197                       count: this.state.unreadInboxCount,
198                       formattedCount: numToSI(this.state.unreadInboxCount),
199                     })}
200                   >
201                     <Icon icon="bell" />
202                     {this.state.unreadInboxCount > 0 && (
203                       <span class="mx-1 badge badge-light">
204                         {numToSI(this.state.unreadInboxCount)}
205                       </span>
206                     )}
207                   </button>
208                 </li>
209               </ul>
210               {UserService.Instance.myUserInfo?.moderates.length > 0 && (
211                 <ul class="navbar-nav ml-1">
212                   <li className="nav-item">
213                     <button
214                       className="p-1 navbar-toggler nav-link border-0 btn btn-link"
215                       onClick={linkEvent(this, this.handleGotoReports)}
216                       title={i18n.t("unread_reports", {
217                         count: this.state.unreadReportCount,
218                         formattedCount: numToSI(this.state.unreadReportCount),
219                       })}
220                     >
221                       <Icon icon="shield" />
222                       {this.state.unreadReportCount > 0 && (
223                         <span class="mx-1 badge badge-light">
224                           {numToSI(this.state.unreadReportCount)}
225                         </span>
226                       )}
227                     </button>
228                   </li>
229                 </ul>
230               )}
231             </>
232           )}
233           <button
234             class="navbar-toggler border-0 p-1"
235             type="button"
236             aria-label="menu"
237             onClick={linkEvent(this, this.expandNavbar)}
238             data-tippy-content={i18n.t("expand_here")}
239           >
240             <Icon icon="menu" />
241           </button>
242           <div
243             className={`${!this.state.expanded && "collapse"} navbar-collapse`}
244           >
245             <ul class="navbar-nav my-2 mr-auto">
246               <li class="nav-item">
247                 <button
248                   className="nav-link btn btn-link"
249                   onClick={linkEvent(this, this.handleGotoCommunities)}
250                   title={i18n.t("communities")}
251                 >
252                   {i18n.t("communities")}
253                 </button>
254               </li>
255               <li class="nav-item">
256                 <button
257                   className="nav-link btn btn-link"
258                   onClick={linkEvent(this, this.handleGotoCreatePost)}
259                   title={i18n.t("create_post")}
260                 >
261                   {i18n.t("create_post")}
262                 </button>
263               </li>
264               {this.canCreateCommunity && (
265                 <li class="nav-item">
266                   <button
267                     className="nav-link btn btn-link"
268                     onClick={linkEvent(this, this.handleGotoCreateCommunity)}
269                     title={i18n.t("create_community")}
270                   >
271                     {i18n.t("create_community")}
272                   </button>
273                 </li>
274               )}
275               <li class="nav-item">
276                 <a
277                   className="nav-link"
278                   title={i18n.t("support_lemmy")}
279                   href={donateLemmyUrl}
280                 >
281                   <Icon icon="heart" classes="small" />
282                 </a>
283               </li>
284             </ul>
285             <ul class="navbar-nav my-2">
286               {this.canAdmin && (
287                 <li className="nav-item">
288                   <button
289                     className="nav-link btn btn-link"
290                     onClick={linkEvent(this, this.handleGotoAdmin)}
291                     title={i18n.t("admin_settings")}
292                   >
293                     <Icon icon="settings" />
294                   </button>
295                 </li>
296               )}
297             </ul>
298             {!this.context.router.history.location.pathname.match(
299               /^\/search/
300             ) && (
301               <form
302                 class="form-inline mr-2"
303                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
304               >
305                 <input
306                   id="search-input"
307                   class={`form-control mr-0 search-input ${
308                     this.state.toggleSearch ? "show-input" : "hide-input"
309                   }`}
310                   onInput={linkEvent(this, this.handleSearchParam)}
311                   value={this.state.searchParam}
312                   ref={this.searchTextField}
313                   type="text"
314                   placeholder={i18n.t("search")}
315                   onBlur={linkEvent(this, this.handleSearchBlur)}
316                 ></input>
317                 <label class="sr-only" htmlFor="search-input">
318                   {i18n.t("search")}
319                 </label>
320                 <button
321                   name="search-btn"
322                   onClick={linkEvent(this, this.handleSearchBtn)}
323                   class="px-1 btn btn-link"
324                   style="color: var(--gray)"
325                   aria-label={i18n.t("search")}
326                 >
327                   <Icon icon="search" />
328                 </button>
329               </form>
330             )}
331             {this.state.isLoggedIn ? (
332               <>
333                 <ul class="navbar-nav my-2">
334                   <li className="nav-item">
335                     <Link
336                       className="nav-link"
337                       to="/inbox"
338                       title={i18n.t("unread_messages", {
339                         count: this.state.unreadInboxCount,
340                         formattedCount: numToSI(this.state.unreadInboxCount),
341                       })}
342                     >
343                       <Icon icon="bell" />
344                       {this.state.unreadInboxCount > 0 && (
345                         <span class="ml-1 badge badge-light">
346                           {numToSI(this.state.unreadInboxCount)}
347                         </span>
348                       )}
349                     </Link>
350                   </li>
351                 </ul>
352                 {UserService.Instance.myUserInfo?.moderates.length > 0 && (
353                   <ul class="navbar-nav my-2">
354                     <li className="nav-item">
355                       <Link
356                         className="nav-link"
357                         to="/reports"
358                         title={i18n.t("unread_reports", {
359                           count: this.state.unreadReportCount,
360                           formattedCount: numToSI(this.state.unreadReportCount),
361                         })}
362                       >
363                         <Icon icon="shield" />
364                         {this.state.unreadReportCount > 0 && (
365                           <span class="ml-1 badge badge-light">
366                             {numToSI(this.state.unreadReportCount)}
367                           </span>
368                         )}
369                       </Link>
370                     </li>
371                   </ul>
372                 )}
373                 <ul class="navbar-nav">
374                   <li class="nav-item dropdown">
375                     <button
376                       class="nav-link btn btn-link dropdown-toggle"
377                       onClick={linkEvent(this, this.handleShowDropdown)}
378                       id="navbarDropdown"
379                       role="button"
380                       aria-expanded="false"
381                     >
382                       <span>
383                         {person.avatar && showAvatars() && (
384                           <PictrsImage src={person.avatar} icon />
385                         )}
386                         {person.display_name
387                           ? person.display_name
388                           : person.name}
389                       </span>
390                     </button>
391                     {this.state.showDropdown && (
392                       <div class="dropdown-content">
393                         <li className="nav-item">
394                           <button
395                             className="nav-link btn btn-link"
396                             onClick={linkEvent(this, this.handleGotoProfile)}
397                             title={i18n.t("profile")}
398                           >
399                             <Icon icon="user" classes="mr-1" />
400                             {i18n.t("profile")}
401                           </button>
402                         </li>
403                         <li className="nav-item">
404                           <button
405                             className="nav-link btn btn-link"
406                             onClick={linkEvent(this, this.handleGotoSettings)}
407                             title={i18n.t("settings")}
408                           >
409                             <Icon icon="settings" classes="mr-1" />
410                             {i18n.t("settings")}
411                           </button>
412                         </li>
413                         <li>
414                           <hr class="dropdown-divider" />
415                         </li>
416                         <li className="nav-item">
417                           <button
418                             className="nav-link btn btn-link"
419                             onClick={linkEvent(this, this.handleLogoutClick)}
420                             title="test"
421                           >
422                             <Icon icon="log-out" classes="mr-1" />
423                             {i18n.t("logout")}
424                           </button>
425                         </li>
426                       </div>
427                     )}
428                   </li>
429                 </ul>
430               </>
431             ) : (
432               <ul class="navbar-nav my-2">
433                 <li className="nav-item">
434                   <button
435                     className="nav-link btn btn-link"
436                     onClick={linkEvent(this, this.handleGotoLogin)}
437                     title={i18n.t("login")}
438                   >
439                     {i18n.t("login")}
440                   </button>
441                 </li>
442                 <li className="nav-item">
443                   <button
444                     className="nav-link btn btn-link"
445                     onClick={linkEvent(this, this.handleGotoSignup)}
446                     title={i18n.t("sign_up")}
447                   >
448                     {i18n.t("sign_up")}
449                   </button>
450                 </li>
451               </ul>
452             )}
453           </div>
454         </div>
455       </nav>
456     );
457   }
458
459   expandNavbar(i: Navbar) {
460     i.state.expanded = !i.state.expanded;
461     i.setState(i.state);
462   }
463
464   handleSearchParam(i: Navbar, event: any) {
465     i.state.searchParam = event.target.value;
466     i.setState(i.state);
467   }
468
469   handleSearchSubmit(i: Navbar, event: any) {
470     event.preventDefault();
471     i.updateUrl();
472   }
473
474   handleSearchBtn(i: Navbar, event: any) {
475     event.preventDefault();
476     i.setState({ toggleSearch: true });
477
478     i.searchTextField.current.focus();
479     const offsetWidth = i.searchTextField.current.offsetWidth;
480     if (i.state.searchParam && offsetWidth > 100) {
481       i.updateUrl();
482     }
483   }
484
485   handleSearchBlur(i: Navbar, event: any) {
486     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
487       i.state.toggleSearch = false;
488       i.setState(i.state);
489     }
490   }
491
492   handleLogoutClick(i: Navbar) {
493     i.setState({ showDropdown: false, expanded: false });
494     UserService.Instance.logout();
495     window.location.href = "/";
496     location.reload();
497   }
498
499   handleGotoSettings(i: Navbar) {
500     i.setState({ showDropdown: false, expanded: false });
501     i.context.router.history.push("/settings");
502   }
503
504   handleGotoProfile(i: Navbar) {
505     i.setState({ showDropdown: false, expanded: false });
506     i.context.router.history.push(
507       `/u/${UserService.Instance.myUserInfo.local_user_view.person.name}`
508     );
509   }
510
511   handleGotoCreatePost(i: Navbar) {
512     i.setState({ showDropdown: false, expanded: false });
513     i.context.router.history.push("/create_post", {
514       prevPath: i.currentLocation,
515     });
516   }
517
518   handleGotoCreateCommunity(i: Navbar) {
519     i.setState({ showDropdown: false, expanded: false });
520     i.context.router.history.push(`/create_community`);
521   }
522
523   handleGotoCommunities(i: Navbar) {
524     i.setState({ showDropdown: false, expanded: false });
525     i.context.router.history.push(`/communities`);
526   }
527
528   handleGotoHome(i: Navbar) {
529     i.setState({ showDropdown: false, expanded: false });
530     i.context.router.history.push(`/`);
531   }
532
533   handleGotoInbox(i: Navbar) {
534     i.setState({ showDropdown: false, expanded: false });
535     i.context.router.history.push(`/inbox`);
536   }
537
538   handleGotoReports(i: Navbar) {
539     i.setState({ showDropdown: false, expanded: false });
540     i.context.router.history.push(`/reports`);
541   }
542
543   handleGotoAdmin(i: Navbar) {
544     i.setState({ showDropdown: false, expanded: false });
545     i.context.router.history.push(`/admin`);
546   }
547
548   handleGotoLogin(i: Navbar) {
549     i.setState({ showDropdown: false, expanded: false });
550     i.context.router.history.push(`/login`);
551   }
552
553   handleGotoSignup(i: Navbar) {
554     i.setState({ showDropdown: false, expanded: false });
555     i.context.router.history.push(`/signup`);
556   }
557
558   handleShowDropdown(i: Navbar) {
559     i.state.showDropdown = !i.state.showDropdown;
560     i.setState(i.state);
561   }
562
563   parseMessage(msg: any) {
564     let op = wsUserOp(msg);
565     console.log(msg);
566     if (msg.error) {
567       if (msg.error == "not_logged_in") {
568         UserService.Instance.logout();
569         location.reload();
570       }
571       return;
572     } else if (msg.reconnect) {
573       console.log(i18n.t("websocket_reconnected"));
574       WebSocketService.Instance.send(
575         wsClient.userJoin({
576           auth: authField(),
577         })
578       );
579       this.fetchUnreads();
580     } else if (op == UserOperation.GetReplies) {
581       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
582       let unreadReplies = data.replies.filter(r => !r.comment.read);
583
584       this.state.replies = unreadReplies;
585       this.state.unreadInboxCount = this.calculateUnreadInboxCount();
586       this.setState(this.state);
587       this.sendUnreadCount();
588     } else if (op == UserOperation.GetPersonMentions) {
589       let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data;
590       let unreadMentions = data.mentions.filter(r => !r.comment.read);
591
592       this.state.mentions = unreadMentions;
593       this.state.unreadInboxCount = this.calculateUnreadInboxCount();
594       this.setState(this.state);
595       this.sendUnreadCount();
596     } else if (op == UserOperation.GetPrivateMessages) {
597       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
598       let unreadMessages = data.private_messages.filter(
599         r => !r.private_message.read
600       );
601
602       this.state.messages = unreadMessages;
603       this.state.unreadInboxCount = this.calculateUnreadInboxCount();
604       this.setState(this.state);
605       this.sendUnreadCount();
606     } else if (op == UserOperation.GetReportCount) {
607       let data = wsJsonToRes<GetReportCountResponse>(msg).data;
608       this.state.unreadReportCount = data.post_reports + data.comment_reports;
609       this.setState(this.state);
610       this.sendReportUnread();
611     } else if (op == UserOperation.GetSite) {
612       // This is only called on a successful login
613       let data = wsJsonToRes<GetSiteResponse>(msg).data;
614       console.log(data.my_user);
615       UserService.Instance.myUserInfo = data.my_user;
616       setTheme(
617         UserService.Instance.myUserInfo.local_user_view.local_user.theme
618       );
619       i18n.changeLanguage(getLanguage());
620       this.state.isLoggedIn = true;
621       this.setState(this.state);
622     } else if (op == UserOperation.CreateComment) {
623       let data = wsJsonToRes<CommentResponse>(msg).data;
624
625       if (this.state.isLoggedIn) {
626         if (
627           data.recipient_ids.includes(
628             UserService.Instance.myUserInfo.local_user_view.local_user.id
629           )
630         ) {
631           this.state.replies.push(data.comment_view);
632           this.state.unreadInboxCount++;
633           this.setState(this.state);
634           this.sendUnreadCount();
635           notifyComment(data.comment_view, this.context.router);
636         }
637       }
638     } else if (op == UserOperation.CreatePrivateMessage) {
639       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
640
641       if (this.state.isLoggedIn) {
642         if (
643           data.private_message_view.recipient.id ==
644           UserService.Instance.myUserInfo.local_user_view.person.id
645         ) {
646           this.state.messages.push(data.private_message_view);
647           this.state.unreadInboxCount++;
648           this.setState(this.state);
649           this.sendUnreadCount();
650           notifyPrivateMessage(data.private_message_view, this.context.router);
651         }
652       }
653     }
654   }
655
656   fetchUnreads() {
657     // TODO we should just add a count call to the API for these, because this is a limited fetch,
658     // and it shouldn't have to fetch the actual content
659     if (this.currentLocation !== "/inbox") {
660       console.log("Fetching inbox unreads...");
661       let repliesForm: GetReplies = {
662         sort: SortType.New,
663         unread_only: true,
664         page: 1,
665         limit: fetchLimit,
666         auth: authField(),
667       };
668
669       let personMentionsForm: GetPersonMentions = {
670         sort: SortType.New,
671         unread_only: true,
672         page: 1,
673         limit: fetchLimit,
674         auth: authField(),
675       };
676
677       let privateMessagesForm: GetPrivateMessages = {
678         unread_only: true,
679         page: 1,
680         limit: fetchLimit,
681         auth: authField(),
682       };
683
684       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
685       WebSocketService.Instance.send(
686         wsClient.getPersonMentions(personMentionsForm)
687       );
688       WebSocketService.Instance.send(
689         wsClient.getPrivateMessages(privateMessagesForm)
690       );
691     }
692
693     console.log("Fetching reports...");
694
695     let reportCountForm: GetReportCount = {
696       auth: authField(),
697     };
698
699     WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
700   }
701
702   get currentLocation() {
703     return this.context.router.history.location.pathname;
704   }
705
706   sendUnreadCount() {
707     UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
708   }
709
710   sendReportUnread() {
711     UserService.Instance.unreadReportCountSub.next(
712       this.state.unreadReportCount
713     );
714   }
715
716   calculateUnreadInboxCount(): number {
717     return (
718       this.state.replies.filter(r => !r.comment.read).length +
719       this.state.mentions.filter(r => !r.comment.read).length +
720       this.state.messages.filter(r => !r.private_message.read).length
721     );
722   }
723
724   get canAdmin(): boolean {
725     return (
726       UserService.Instance.myUserInfo &&
727       this.props.site_res.admins
728         .map(a => a.person.id)
729         .includes(UserService.Instance.myUserInfo.local_user_view.person.id)
730     );
731   }
732
733   get canCreateCommunity(): boolean {
734     let adminOnly =
735       this.props.site_res.site_view?.site.community_creation_admin_only;
736     return !adminOnly || this.canAdmin;
737   }
738
739   /// Listens for some websocket errors
740   websocketEvents() {
741     let msg = i18n.t("websocket_disconnected");
742     WebSocketService.Instance.closeEventListener(() => {
743       console.error(msg);
744     });
745   }
746
747   requestNotificationPermission() {
748     if (UserService.Instance.myUserInfo) {
749       document.addEventListener("DOMContentLoaded", function () {
750         if (!Notification) {
751           toast(i18n.t("notifications_error"), "danger");
752           return;
753         }
754
755         if (Notification.permission !== "granted")
756           Notification.requestPermission();
757       });
758     }
759   }
760 }