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