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