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