]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Merge branch 'fix/notif_new_fetch_bug' of https://github.com/ernestwisniewski/lemmy...
[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 class="navbar navbar-expand-md navbar-light shadow-sm p-0 px-3">
148         <div class="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 class="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 class="mx-1 badge badge-light">
183                         {numToSI(this.state.unreadInboxCount)}
184                       </span>
185                     )}
186                   </NavLink>
187                 </li>
188               </ul>
189               {this.moderatesSomething && (
190                 <ul class="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 class="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 class="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 class="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             class="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 class="navbar-nav my-2 mr-auto">
250               <li class="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 class="nav-item">
261                 <NavLink
262                   to={{
263                     pathname: "/create_post",
264                     prevPath: this.currentLocation,
265                   }}
266                   className="nav-link"
267                   onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
268                   title={i18n.t("create_post")}
269                 >
270                   {i18n.t("create_post")}
271                 </NavLink>
272               </li>
273               {this.canCreateCommunity && (
274                 <li class="nav-item">
275                   <NavLink
276                     to="/create_community"
277                     className="nav-link"
278                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
279                     title={i18n.t("create_community")}
280                   >
281                     {i18n.t("create_community")}
282                   </NavLink>
283                 </li>
284               )}
285               <li class="nav-item">
286                 <a
287                   className="nav-link"
288                   title={i18n.t("support_lemmy")}
289                   href={donateLemmyUrl}
290                 >
291                   <Icon icon="heart" classes="small" />
292                 </a>
293               </li>
294             </ul>
295             <ul class="navbar-nav my-2">
296               {this.amAdmin && (
297                 <li className="nav-item">
298                   <NavLink
299                     to="/admin"
300                     className="nav-link"
301                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
302                     title={i18n.t("admin_settings")}
303                   >
304                     <Icon icon="settings" />
305                   </NavLink>
306                 </li>
307               )}
308             </ul>
309             {!this.context.router.history.location.pathname.match(
310               /^\/search/
311             ) && (
312               <form
313                 class="form-inline mr-2"
314                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
315               >
316                 <input
317                   id="search-input"
318                   class={`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                   type="text"
325                   placeholder={i18n.t("search")}
326                   onBlur={linkEvent(this, this.handleSearchBlur)}
327                 ></input>
328                 <label class="sr-only" htmlFor="search-input">
329                   {i18n.t("search")}
330                 </label>
331                 <button
332                   name="search-btn"
333                   onClick={linkEvent(this, this.handleSearchBtn)}
334                   class="px-1 btn btn-link"
335                   style="color: var(--gray)"
336                   aria-label={i18n.t("search")}
337                 >
338                   <Icon icon="search" />
339                 </button>
340               </form>
341             )}
342             {UserService.Instance.myUserInfo.isSome() ? (
343               <>
344                 <ul class="navbar-nav my-2">
345                   <li className="nav-item">
346                     <NavLink
347                       className="nav-link"
348                       to="/inbox"
349                       onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
350                       title={i18n.t("unread_messages", {
351                         count: this.state.unreadInboxCount,
352                         formattedCount: numToSI(this.state.unreadInboxCount),
353                       })}
354                     >
355                       <Icon icon="bell" />
356                       {this.state.unreadInboxCount > 0 && (
357                         <span class="ml-1 badge badge-light">
358                           {numToSI(this.state.unreadInboxCount)}
359                         </span>
360                       )}
361                     </NavLink>
362                   </li>
363                 </ul>
364                 {this.moderatesSomething && (
365                   <ul class="navbar-nav my-2">
366                     <li className="nav-item">
367                       <NavLink
368                         className="nav-link"
369                         to="/reports"
370                         onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
371                         title={i18n.t("unread_reports", {
372                           count: this.state.unreadReportCount,
373                           formattedCount: numToSI(this.state.unreadReportCount),
374                         })}
375                       >
376                         <Icon icon="shield" />
377                         {this.state.unreadReportCount > 0 && (
378                           <span class="ml-1 badge badge-light">
379                             {numToSI(this.state.unreadReportCount)}
380                           </span>
381                         )}
382                       </NavLink>
383                     </li>
384                   </ul>
385                 )}
386                 {this.amAdmin && (
387                   <ul class="navbar-nav my-2">
388                     <li className="nav-item">
389                       <NavLink
390                         to="/registration_applications"
391                         className="nav-link"
392                         onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
393                         title={i18n.t("unread_registration_applications", {
394                           count: this.state.unreadApplicationCount,
395                           formattedCount: numToSI(
396                             this.state.unreadApplicationCount
397                           ),
398                         })}
399                       >
400                         <Icon icon="clipboard" />
401                         {this.state.unreadApplicationCount > 0 && (
402                           <span class="mx-1 badge badge-light">
403                             {numToSI(this.state.unreadApplicationCount)}
404                           </span>
405                         )}
406                       </NavLink>
407                     </li>
408                   </ul>
409                 )}
410                 {UserService.Instance.myUserInfo
411                   .map(m => m.local_user_view.person)
412                   .match({
413                     some: person => (
414                       <ul class="navbar-nav">
415                         <li class="nav-item dropdown">
416                           <button
417                             class="nav-link btn btn-link dropdown-toggle"
418                             onClick={linkEvent(this, this.handleToggleDropdown)}
419                             id="navbarDropdown"
420                             role="button"
421                             aria-expanded="false"
422                           >
423                             <span>
424                               {showAvatars() &&
425                                 person.avatar.match({
426                                   some: avatar => (
427                                     <PictrsImage src={avatar} icon />
428                                   ),
429                                   none: <></>,
430                                 })}
431                               {person.display_name.unwrapOr(person.name)}
432                             </span>
433                           </button>
434                           {this.state.showDropdown && (
435                             <div
436                               class="dropdown-content"
437                               onMouseLeave={linkEvent(
438                                 this,
439                                 this.handleToggleDropdown
440                               )}
441                             >
442                               <li className="nav-item">
443                                 <NavLink
444                                   to={`/u/${person.name}`}
445                                   className="nav-link"
446                                   title={i18n.t("profile")}
447                                 >
448                                   <Icon icon="user" classes="mr-1" />
449                                   {i18n.t("profile")}
450                                 </NavLink>
451                               </li>
452                               <li className="nav-item">
453                                 <NavLink
454                                   to="/settings"
455                                   className="nav-link"
456                                   title={i18n.t("settings")}
457                                 >
458                                   <Icon icon="settings" classes="mr-1" />
459                                   {i18n.t("settings")}
460                                 </NavLink>
461                               </li>
462                               <li>
463                                 <hr class="dropdown-divider" />
464                               </li>
465                               <li className="nav-item">
466                                 <button
467                                   className="nav-link btn btn-link"
468                                   onClick={linkEvent(
469                                     this,
470                                     this.handleLogoutClick
471                                   )}
472                                   title="test"
473                                 >
474                                   <Icon icon="log-out" classes="mr-1" />
475                                   {i18n.t("logout")}
476                                 </button>
477                               </li>
478                             </div>
479                           )}
480                         </li>
481                       </ul>
482                     ),
483                     none: <></>,
484                   })}
485               </>
486             ) : (
487               <ul class="navbar-nav my-2">
488                 <li className="nav-item">
489                   <NavLink
490                     to="/login"
491                     className="nav-link"
492                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
493                     title={i18n.t("login")}
494                   >
495                     {i18n.t("login")}
496                   </NavLink>
497                 </li>
498                 <li className="nav-item">
499                   <NavLink
500                     to="/signup"
501                     className="nav-link"
502                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
503                     title={i18n.t("sign_up")}
504                   >
505                     {i18n.t("sign_up")}
506                   </NavLink>
507                 </li>
508               </ul>
509             )}
510           </div>
511         </div>
512       </nav>
513     );
514   }
515
516   get moderatesSomething(): boolean {
517     return (
518       UserService.Instance.myUserInfo.map(m => m.moderates).unwrapOr([])
519         .length > 0
520     );
521   }
522
523   get amAdmin(): boolean {
524     return amAdmin(Some(this.props.siteRes.admins));
525   }
526
527   get canCreateCommunity(): boolean {
528     let adminOnly = this.props.siteRes.site_view
529       .map(s => s.site.community_creation_admin_only)
530       .unwrapOr(false);
531     return !adminOnly || this.amAdmin;
532   }
533
534   handleToggleExpandNavbar(i: Navbar) {
535     i.state.expanded = !i.state.expanded;
536     i.setState(i.state);
537   }
538
539   handleHideExpandNavbar(i: Navbar) {
540     i.setState({ expanded: false, showDropdown: false });
541   }
542
543   handleSearchParam(i: Navbar, event: any) {
544     i.state.searchParam = event.target.value;
545     i.setState(i.state);
546   }
547
548   handleSearchSubmit(i: Navbar, event: any) {
549     event.preventDefault();
550     i.updateUrl();
551   }
552
553   handleSearchBtn(i: Navbar, event: any) {
554     event.preventDefault();
555     i.setState({ toggleSearch: true });
556
557     i.searchTextField.current.focus();
558     const offsetWidth = i.searchTextField.current.offsetWidth;
559     if (i.state.searchParam && offsetWidth > 100) {
560       i.updateUrl();
561     }
562   }
563
564   handleSearchBlur(i: Navbar, event: any) {
565     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
566       i.state.toggleSearch = false;
567       i.setState(i.state);
568     }
569   }
570
571   handleLogoutClick(i: Navbar) {
572     i.setState({ showDropdown: false, expanded: false });
573     UserService.Instance.logout();
574   }
575
576   handleToggleDropdown(i: Navbar) {
577     i.state.showDropdown = !i.state.showDropdown;
578     i.setState(i.state);
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.state.unreadInboxCount =
605         data.replies + data.mentions + data.private_messages;
606       this.setState(this.state);
607       this.sendUnreadCount();
608     } else if (op == UserOperation.GetReportCount) {
609       let data = wsJsonToRes<GetReportCountResponse>(
610         msg,
611         GetReportCountResponse
612       );
613       this.state.unreadReportCount = data.post_reports + data.comment_reports;
614       this.setState(this.state);
615       this.sendReportUnread();
616     } else if (op == UserOperation.GetUnreadRegistrationApplicationCount) {
617       let data = wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(
618         msg,
619         GetUnreadRegistrationApplicationCountResponse
620       );
621       this.state.unreadApplicationCount = data.registration_applications;
622       this.setState(this.state);
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.state.unreadInboxCount++;
631             this.setState(this.state);
632             this.sendUnreadCount();
633             notifyComment(data.comment_view, this.context.router);
634           }
635         },
636         none: void 0,
637       });
638     } else if (op == UserOperation.CreatePrivateMessage) {
639       let data = wsJsonToRes<PrivateMessageResponse>(
640         msg,
641         PrivateMessageResponse
642       );
643
644       UserService.Instance.myUserInfo.match({
645         some: mui => {
646           if (
647             data.private_message_view.recipient.id ==
648             mui.local_user_view.person.id
649           ) {
650             this.state.unreadInboxCount++;
651             this.setState(this.state);
652             this.sendUnreadCount();
653             notifyPrivateMessage(
654               data.private_message_view,
655               this.context.router
656             );
657           }
658         },
659         none: void 0,
660       });
661     }
662   }
663
664   fetchUnreads() {
665     console.log("Fetching inbox unreads...");
666
667     let unreadForm = new GetUnreadCount({
668       auth: auth().unwrap(),
669     });
670     WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm));
671
672     console.log("Fetching reports...");
673
674     let reportCountForm = new GetReportCount({
675       community_id: None,
676       auth: auth().unwrap(),
677     });
678     WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
679
680     if (this.amAdmin) {
681       console.log("Fetching applications...");
682
683       let applicationCountForm = new GetUnreadRegistrationApplicationCount({
684         auth: auth().unwrap(),
685       });
686       WebSocketService.Instance.send(
687         wsClient.getUnreadRegistrationApplicationCount(applicationCountForm)
688       );
689     }
690   }
691
692   get currentLocation() {
693     return this.context.router.history.location.pathname;
694   }
695
696   sendUnreadCount() {
697     UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
698   }
699
700   sendReportUnread() {
701     UserService.Instance.unreadReportCountSub.next(
702       this.state.unreadReportCount
703     );
704   }
705
706   sendApplicationUnread() {
707     UserService.Instance.unreadApplicationCountSub.next(
708       this.state.unreadApplicationCount
709     );
710   }
711
712   requestNotificationPermission() {
713     if (UserService.Instance.myUserInfo.isSome()) {
714       document.addEventListener("DOMContentLoaded", function () {
715         if (!Notification) {
716           toast(i18n.t("notifications_error"), "danger");
717           return;
718         }
719
720         if (Notification.permission !== "granted")
721           Notification.requestPermission();
722       });
723     }
724   }
725 }