]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Simplifying getunreadcount. (#455)
[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             <button
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 btn btn-link"
164               onClick={linkEvent(this, this.handleGotoHome)}
165             >
166               {this.props.site_res.site_view.site.icon && showAvatars() && (
167                 <PictrsImage
168                   src={this.props.site_res.site_view.site.icon}
169                   icon
170                 />
171               )}
172               {this.props.site_res.site_view.site.name}
173             </button>
174           )}
175           {this.state.isLoggedIn && (
176             <>
177               <ul class="navbar-nav ml-auto">
178                 <li className="nav-item">
179                   <button
180                     className="p-1 navbar-toggler nav-link border-0 btn btn-link"
181                     onClick={linkEvent(this, this.handleGotoInbox)}
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                   </button>
194                 </li>
195               </ul>
196               {UserService.Instance.myUserInfo?.moderates.length > 0 && (
197                 <ul class="navbar-nav ml-1">
198                   <li className="nav-item">
199                     <button
200                       className="p-1 navbar-toggler nav-link border-0 btn btn-link"
201                       onClick={linkEvent(this, this.handleGotoReports)}
202                       title={i18n.t("unread_reports", {
203                         count: this.state.unreadReportCount,
204                         formattedCount: numToSI(this.state.unreadReportCount),
205                       })}
206                     >
207                       <Icon icon="shield" />
208                       {this.state.unreadReportCount > 0 && (
209                         <span class="mx-1 badge badge-light">
210                           {numToSI(this.state.unreadReportCount)}
211                         </span>
212                       )}
213                     </button>
214                   </li>
215                 </ul>
216               )}
217             </>
218           )}
219           <button
220             class="navbar-toggler border-0 p-1"
221             type="button"
222             aria-label="menu"
223             onClick={linkEvent(this, this.expandNavbar)}
224             data-tippy-content={i18n.t("expand_here")}
225           >
226             <Icon icon="menu" />
227           </button>
228           <div
229             className={`${!this.state.expanded && "collapse"} navbar-collapse`}
230           >
231             <ul class="navbar-nav my-2 mr-auto">
232               <li class="nav-item">
233                 <button
234                   className="nav-link btn btn-link"
235                   onClick={linkEvent(this, this.handleGotoCommunities)}
236                   title={i18n.t("communities")}
237                 >
238                   {i18n.t("communities")}
239                 </button>
240               </li>
241               <li class="nav-item">
242                 <button
243                   className="nav-link btn btn-link"
244                   onClick={linkEvent(this, this.handleGotoCreatePost)}
245                   title={i18n.t("create_post")}
246                 >
247                   {i18n.t("create_post")}
248                 </button>
249               </li>
250               {this.canCreateCommunity && (
251                 <li class="nav-item">
252                   <button
253                     className="nav-link btn btn-link"
254                     onClick={linkEvent(this, this.handleGotoCreateCommunity)}
255                     title={i18n.t("create_community")}
256                   >
257                     {i18n.t("create_community")}
258                   </button>
259                 </li>
260               )}
261               <li class="nav-item">
262                 <a
263                   className="nav-link"
264                   title={i18n.t("support_lemmy")}
265                   href={donateLemmyUrl}
266                 >
267                   <Icon icon="heart" classes="small" />
268                 </a>
269               </li>
270             </ul>
271             <ul class="navbar-nav my-2">
272               {this.canAdmin && (
273                 <li className="nav-item">
274                   <button
275                     className="nav-link btn btn-link"
276                     onClick={linkEvent(this, this.handleGotoAdmin)}
277                     title={i18n.t("admin_settings")}
278                   >
279                     <Icon icon="settings" />
280                   </button>
281                 </li>
282               )}
283             </ul>
284             {!this.context.router.history.location.pathname.match(
285               /^\/search/
286             ) && (
287               <form
288                 class="form-inline mr-2"
289                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
290               >
291                 <input
292                   id="search-input"
293                   class={`form-control mr-0 search-input ${
294                     this.state.toggleSearch ? "show-input" : "hide-input"
295                   }`}
296                   onInput={linkEvent(this, this.handleSearchParam)}
297                   value={this.state.searchParam}
298                   ref={this.searchTextField}
299                   type="text"
300                   placeholder={i18n.t("search")}
301                   onBlur={linkEvent(this, this.handleSearchBlur)}
302                 ></input>
303                 <label class="sr-only" htmlFor="search-input">
304                   {i18n.t("search")}
305                 </label>
306                 <button
307                   name="search-btn"
308                   onClick={linkEvent(this, this.handleSearchBtn)}
309                   class="px-1 btn btn-link"
310                   style="color: var(--gray)"
311                   aria-label={i18n.t("search")}
312                 >
313                   <Icon icon="search" />
314                 </button>
315               </form>
316             )}
317             {this.state.isLoggedIn ? (
318               <>
319                 <ul class="navbar-nav my-2">
320                   <li className="nav-item">
321                     <Link
322                       className="nav-link"
323                       to="/inbox"
324                       title={i18n.t("unread_messages", {
325                         count: this.state.unreadInboxCount,
326                         formattedCount: numToSI(this.state.unreadInboxCount),
327                       })}
328                     >
329                       <Icon icon="bell" />
330                       {this.state.unreadInboxCount > 0 && (
331                         <span class="ml-1 badge badge-light">
332                           {numToSI(this.state.unreadInboxCount)}
333                         </span>
334                       )}
335                     </Link>
336                   </li>
337                 </ul>
338                 {UserService.Instance.myUserInfo?.moderates.length > 0 && (
339                   <ul class="navbar-nav my-2">
340                     <li className="nav-item">
341                       <Link
342                         className="nav-link"
343                         to="/reports"
344                         title={i18n.t("unread_reports", {
345                           count: this.state.unreadReportCount,
346                           formattedCount: numToSI(this.state.unreadReportCount),
347                         })}
348                       >
349                         <Icon icon="shield" />
350                         {this.state.unreadReportCount > 0 && (
351                           <span class="ml-1 badge badge-light">
352                             {numToSI(this.state.unreadReportCount)}
353                           </span>
354                         )}
355                       </Link>
356                     </li>
357                   </ul>
358                 )}
359                 <ul class="navbar-nav">
360                   <li class="nav-item dropdown">
361                     <button
362                       class="nav-link btn btn-link dropdown-toggle"
363                       onClick={linkEvent(this, this.handleShowDropdown)}
364                       id="navbarDropdown"
365                       role="button"
366                       aria-expanded="false"
367                     >
368                       <span>
369                         {person.avatar && showAvatars() && (
370                           <PictrsImage src={person.avatar} icon />
371                         )}
372                         {person.display_name
373                           ? person.display_name
374                           : person.name}
375                       </span>
376                     </button>
377                     {this.state.showDropdown && (
378                       <div class="dropdown-content">
379                         <li className="nav-item">
380                           <button
381                             className="nav-link btn btn-link"
382                             onClick={linkEvent(this, this.handleGotoProfile)}
383                             title={i18n.t("profile")}
384                           >
385                             <Icon icon="user" classes="mr-1" />
386                             {i18n.t("profile")}
387                           </button>
388                         </li>
389                         <li className="nav-item">
390                           <button
391                             className="nav-link btn btn-link"
392                             onClick={linkEvent(this, this.handleGotoSettings)}
393                             title={i18n.t("settings")}
394                           >
395                             <Icon icon="settings" classes="mr-1" />
396                             {i18n.t("settings")}
397                           </button>
398                         </li>
399                         <li>
400                           <hr class="dropdown-divider" />
401                         </li>
402                         <li className="nav-item">
403                           <button
404                             className="nav-link btn btn-link"
405                             onClick={linkEvent(this, this.handleLogoutClick)}
406                             title="test"
407                           >
408                             <Icon icon="log-out" classes="mr-1" />
409                             {i18n.t("logout")}
410                           </button>
411                         </li>
412                       </div>
413                     )}
414                   </li>
415                 </ul>
416               </>
417             ) : (
418               <ul class="navbar-nav my-2">
419                 <li className="nav-item">
420                   <button
421                     className="nav-link btn btn-link"
422                     onClick={linkEvent(this, this.handleGotoLogin)}
423                     title={i18n.t("login")}
424                   >
425                     {i18n.t("login")}
426                   </button>
427                 </li>
428                 <li className="nav-item">
429                   <button
430                     className="nav-link btn btn-link"
431                     onClick={linkEvent(this, this.handleGotoSignup)}
432                     title={i18n.t("sign_up")}
433                   >
434                     {i18n.t("sign_up")}
435                   </button>
436                 </li>
437               </ul>
438             )}
439           </div>
440         </div>
441       </nav>
442     );
443   }
444
445   expandNavbar(i: Navbar) {
446     i.state.expanded = !i.state.expanded;
447     i.setState(i.state);
448   }
449
450   handleSearchParam(i: Navbar, event: any) {
451     i.state.searchParam = event.target.value;
452     i.setState(i.state);
453   }
454
455   handleSearchSubmit(i: Navbar, event: any) {
456     event.preventDefault();
457     i.updateUrl();
458   }
459
460   handleSearchBtn(i: Navbar, event: any) {
461     event.preventDefault();
462     i.setState({ toggleSearch: true });
463
464     i.searchTextField.current.focus();
465     const offsetWidth = i.searchTextField.current.offsetWidth;
466     if (i.state.searchParam && offsetWidth > 100) {
467       i.updateUrl();
468     }
469   }
470
471   handleSearchBlur(i: Navbar, event: any) {
472     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
473       i.state.toggleSearch = false;
474       i.setState(i.state);
475     }
476   }
477
478   handleLogoutClick(i: Navbar) {
479     i.setState({ showDropdown: false, expanded: false });
480     UserService.Instance.logout();
481     window.location.href = "/";
482     location.reload();
483   }
484
485   handleGotoSettings(i: Navbar) {
486     i.setState({ showDropdown: false, expanded: false });
487     i.context.router.history.push("/settings");
488   }
489
490   handleGotoProfile(i: Navbar) {
491     i.setState({ showDropdown: false, expanded: false });
492     i.context.router.history.push(
493       `/u/${UserService.Instance.myUserInfo.local_user_view.person.name}`
494     );
495   }
496
497   handleGotoCreatePost(i: Navbar) {
498     i.setState({ showDropdown: false, expanded: false });
499     i.context.router.history.push("/create_post", {
500       prevPath: i.currentLocation,
501     });
502   }
503
504   handleGotoCreateCommunity(i: Navbar) {
505     i.setState({ showDropdown: false, expanded: false });
506     i.context.router.history.push(`/create_community`);
507   }
508
509   handleGotoCommunities(i: Navbar) {
510     i.setState({ showDropdown: false, expanded: false });
511     i.context.router.history.push(`/communities`);
512   }
513
514   handleGotoHome(i: Navbar) {
515     i.setState({ showDropdown: false, expanded: false });
516     i.context.router.history.push(`/`);
517   }
518
519   handleGotoInbox(i: Navbar) {
520     i.setState({ showDropdown: false, expanded: false });
521     i.context.router.history.push(`/inbox`);
522   }
523
524   handleGotoReports(i: Navbar) {
525     i.setState({ showDropdown: false, expanded: false });
526     i.context.router.history.push(`/reports`);
527   }
528
529   handleGotoAdmin(i: Navbar) {
530     i.setState({ showDropdown: false, expanded: false });
531     i.context.router.history.push(`/admin`);
532   }
533
534   handleGotoLogin(i: Navbar) {
535     i.setState({ showDropdown: false, expanded: false });
536     i.context.router.history.push(`/login`);
537   }
538
539   handleGotoSignup(i: Navbar) {
540     i.setState({ showDropdown: false, expanded: false });
541     i.context.router.history.push(`/signup`);
542   }
543
544   handleShowDropdown(i: Navbar) {
545     i.state.showDropdown = !i.state.showDropdown;
546     i.setState(i.state);
547   }
548
549   parseMessage(msg: any) {
550     let op = wsUserOp(msg);
551     console.log(msg);
552     if (msg.error) {
553       if (msg.error == "not_logged_in") {
554         UserService.Instance.logout();
555         location.reload();
556       }
557       return;
558     } else if (msg.reconnect) {
559       console.log(i18n.t("websocket_reconnected"));
560       WebSocketService.Instance.send(
561         wsClient.userJoin({
562           auth: authField(),
563         })
564       );
565       this.fetchUnreads();
566     } else if (op == UserOperation.GetUnreadCount) {
567       let data = wsJsonToRes<GetUnreadCountResponse>(msg).data;
568       this.state.unreadInboxCount =
569         data.replies + data.mentions + data.private_messages;
570       this.setState(this.state);
571       this.sendUnreadCount();
572     } else if (op == UserOperation.GetReportCount) {
573       let data = wsJsonToRes<GetReportCountResponse>(msg).data;
574       this.state.unreadReportCount = data.post_reports + data.comment_reports;
575       this.setState(this.state);
576       this.sendReportUnread();
577     } else if (op == UserOperation.GetSite) {
578       // This is only called on a successful login
579       let data = wsJsonToRes<GetSiteResponse>(msg).data;
580       console.log(data.my_user);
581       UserService.Instance.myUserInfo = data.my_user;
582       setTheme(
583         UserService.Instance.myUserInfo.local_user_view.local_user.theme
584       );
585       i18n.changeLanguage(getLanguage());
586       this.state.isLoggedIn = true;
587       this.setState(this.state);
588     } else if (op == UserOperation.CreateComment) {
589       let data = wsJsonToRes<CommentResponse>(msg).data;
590
591       if (this.state.isLoggedIn) {
592         if (
593           data.recipient_ids.includes(
594             UserService.Instance.myUserInfo.local_user_view.local_user.id
595           )
596         ) {
597           this.state.unreadInboxCount++;
598           this.setState(this.state);
599           this.sendUnreadCount();
600           notifyComment(data.comment_view, this.context.router);
601         }
602       }
603     } else if (op == UserOperation.CreatePrivateMessage) {
604       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
605
606       if (this.state.isLoggedIn) {
607         if (
608           data.private_message_view.recipient.id ==
609           UserService.Instance.myUserInfo.local_user_view.person.id
610         ) {
611           this.state.unreadInboxCount++;
612           this.setState(this.state);
613           this.sendUnreadCount();
614           notifyPrivateMessage(data.private_message_view, this.context.router);
615         }
616       }
617     }
618   }
619
620   fetchUnreads() {
621     console.log("Fetching inbox unreads...");
622
623     let unreadForm: GetUnreadCount = {
624       auth: authField(),
625     };
626
627     WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm));
628
629     console.log("Fetching reports...");
630
631     let reportCountForm: GetReportCount = {
632       auth: authField(),
633     };
634
635     WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
636   }
637
638   get currentLocation() {
639     return this.context.router.history.location.pathname;
640   }
641
642   sendUnreadCount() {
643     UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
644   }
645
646   sendReportUnread() {
647     UserService.Instance.unreadReportCountSub.next(
648       this.state.unreadReportCount
649     );
650   }
651
652   get canAdmin(): boolean {
653     return (
654       UserService.Instance.myUserInfo &&
655       this.props.site_res.admins
656         .map(a => a.person.id)
657         .includes(UserService.Instance.myUserInfo.local_user_view.person.id)
658     );
659   }
660
661   get canCreateCommunity(): boolean {
662     let adminOnly =
663       this.props.site_res.site_view?.site.community_creation_admin_only;
664     return !adminOnly || this.canAdmin;
665   }
666
667   /// Listens for some websocket errors
668   websocketEvents() {
669     let msg = i18n.t("websocket_disconnected");
670     WebSocketService.Instance.closeEventListener(() => {
671       console.error(msg);
672     });
673   }
674
675   requestNotificationPermission() {
676     if (UserService.Instance.myUserInfo) {
677       document.addEventListener("DOMContentLoaded", function () {
678         if (!Notification) {
679           toast(i18n.t("notifications_error"), "danger");
680           return;
681         }
682
683         if (Notification.permission !== "granted")
684           Notification.requestPermission();
685       });
686     }
687   }
688 }