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