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