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