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