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