]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Adding private message reporting. Fixes #782 (#806)
[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">
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               <form
318                 className="form-inline mr-2"
319                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
320               >
321                 <input
322                   id="search-input"
323                   className={`form-control mr-0 search-input ${
324                     this.state.toggleSearch ? "show-input" : "hide-input"
325                   }`}
326                   onInput={linkEvent(this, this.handleSearchParam)}
327                   value={this.state.searchParam}
328                   ref={this.searchTextField}
329                   type="text"
330                   placeholder={i18n.t("search")}
331                   onBlur={linkEvent(this, this.handleSearchBlur)}
332                 ></input>
333                 <label className="sr-only" htmlFor="search-input">
334                   {i18n.t("search")}
335                 </label>
336                 <button
337                   name="search-btn"
338                   onClick={linkEvent(this, this.handleSearchBtn)}
339                   className="px-1 btn btn-link"
340                   style="color: var(--gray)"
341                   aria-label={i18n.t("search")}
342                 >
343                   <Icon icon="search" />
344                 </button>
345               </form>
346             )}
347             {UserService.Instance.myUserInfo.isSome() ? (
348               <>
349                 <ul className="navbar-nav my-2">
350                   <li className="nav-item">
351                     <NavLink
352                       className="nav-link"
353                       to="/inbox"
354                       onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
355                       title={i18n.t("unread_messages", {
356                         count: this.state.unreadInboxCount,
357                         formattedCount: numToSI(this.state.unreadInboxCount),
358                       })}
359                     >
360                       <Icon icon="bell" />
361                       {this.state.unreadInboxCount > 0 && (
362                         <span className="ml-1 badge badge-light">
363                           {numToSI(this.state.unreadInboxCount)}
364                         </span>
365                       )}
366                     </NavLink>
367                   </li>
368                 </ul>
369                 {this.moderatesSomething && (
370                   <ul className="navbar-nav my-2">
371                     <li className="nav-item">
372                       <NavLink
373                         className="nav-link"
374                         to="/reports"
375                         onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
376                         title={i18n.t("unread_reports", {
377                           count: this.state.unreadReportCount,
378                           formattedCount: numToSI(this.state.unreadReportCount),
379                         })}
380                       >
381                         <Icon icon="shield" />
382                         {this.state.unreadReportCount > 0 && (
383                           <span className="ml-1 badge badge-light">
384                             {numToSI(this.state.unreadReportCount)}
385                           </span>
386                         )}
387                       </NavLink>
388                     </li>
389                   </ul>
390                 )}
391                 {amAdmin() && (
392                   <ul className="navbar-nav my-2">
393                     <li className="nav-item">
394                       <NavLink
395                         to="/registration_applications"
396                         className="nav-link"
397                         onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
398                         title={i18n.t("unread_registration_applications", {
399                           count: this.state.unreadApplicationCount,
400                           formattedCount: numToSI(
401                             this.state.unreadApplicationCount
402                           ),
403                         })}
404                       >
405                         <Icon icon="clipboard" />
406                         {this.state.unreadApplicationCount > 0 && (
407                           <span className="mx-1 badge badge-light">
408                             {numToSI(this.state.unreadApplicationCount)}
409                           </span>
410                         )}
411                       </NavLink>
412                     </li>
413                   </ul>
414                 )}
415                 {UserService.Instance.myUserInfo
416                   .map(m => m.local_user_view.person)
417                   .match({
418                     some: person => (
419                       <ul className="navbar-nav">
420                         <li className="nav-item dropdown">
421                           <button
422                             className="nav-link btn btn-link dropdown-toggle"
423                             onClick={linkEvent(this, this.handleToggleDropdown)}
424                             id="navbarDropdown"
425                             role="button"
426                             aria-expanded="false"
427                           >
428                             <span>
429                               {showAvatars() &&
430                                 person.avatar.match({
431                                   some: avatar => (
432                                     <PictrsImage src={avatar} icon />
433                                   ),
434                                   none: <></>,
435                                 })}
436                               {person.display_name.unwrapOr(person.name)}
437                             </span>
438                           </button>
439                           {this.state.showDropdown && (
440                             <div
441                               className="dropdown-content"
442                               onMouseLeave={linkEvent(
443                                 this,
444                                 this.handleToggleDropdown
445                               )}
446                             >
447                               <li className="nav-item">
448                                 <NavLink
449                                   to={`/u/${person.name}`}
450                                   className="nav-link"
451                                   title={i18n.t("profile")}
452                                 >
453                                   <Icon icon="user" classes="mr-1" />
454                                   {i18n.t("profile")}
455                                 </NavLink>
456                               </li>
457                               <li className="nav-item">
458                                 <NavLink
459                                   to="/settings"
460                                   className="nav-link"
461                                   title={i18n.t("settings")}
462                                 >
463                                   <Icon icon="settings" classes="mr-1" />
464                                   {i18n.t("settings")}
465                                 </NavLink>
466                               </li>
467                               <li>
468                                 <hr className="dropdown-divider" />
469                               </li>
470                               <li className="nav-item">
471                                 <button
472                                   className="nav-link btn btn-link"
473                                   onClick={linkEvent(
474                                     this,
475                                     this.handleLogoutClick
476                                   )}
477                                   title="test"
478                                 >
479                                   <Icon icon="log-out" classes="mr-1" />
480                                   {i18n.t("logout")}
481                                 </button>
482                               </li>
483                             </div>
484                           )}
485                         </li>
486                       </ul>
487                     ),
488                     none: <></>,
489                   })}
490               </>
491             ) : (
492               <ul className="navbar-nav my-2">
493                 <li className="nav-item">
494                   <NavLink
495                     to="/login"
496                     className="nav-link"
497                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
498                     title={i18n.t("login")}
499                   >
500                     {i18n.t("login")}
501                   </NavLink>
502                 </li>
503                 <li className="nav-item">
504                   <NavLink
505                     to="/signup"
506                     className="nav-link"
507                     onMouseUp={linkEvent(this, this.handleHideExpandNavbar)}
508                     title={i18n.t("sign_up")}
509                   >
510                     {i18n.t("sign_up")}
511                   </NavLink>
512                 </li>
513               </ul>
514             )}
515           </div>
516         </div>
517       </nav>
518     );
519   }
520
521   get moderatesSomething(): boolean {
522     return (
523       UserService.Instance.myUserInfo.map(m => m.moderates).unwrapOr([])
524         .length > 0
525     );
526   }
527
528   handleToggleExpandNavbar(i: Navbar) {
529     i.setState({ expanded: !i.state.expanded });
530   }
531
532   handleHideExpandNavbar(i: Navbar) {
533     i.setState({ expanded: false, showDropdown: false });
534   }
535
536   handleSearchParam(i: Navbar, event: any) {
537     i.setState({ searchParam: event.target.value });
538   }
539
540   handleSearchSubmit(i: Navbar, event: any) {
541     event.preventDefault();
542     i.updateUrl();
543   }
544
545   handleSearchBtn(i: Navbar, event: any) {
546     event.preventDefault();
547     i.setState({ toggleSearch: true });
548
549     i.searchTextField.current.focus();
550     const offsetWidth = i.searchTextField.current.offsetWidth;
551     if (i.state.searchParam && offsetWidth > 100) {
552       i.updateUrl();
553     }
554   }
555
556   handleSearchBlur(i: Navbar, event: any) {
557     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
558       i.setState({ toggleSearch: false });
559     }
560   }
561
562   handleLogoutClick(i: Navbar) {
563     i.setState({ showDropdown: false, expanded: false });
564     UserService.Instance.logout();
565   }
566
567   handleToggleDropdown(i: Navbar) {
568     i.setState({ showDropdown: !i.state.showDropdown });
569   }
570
571   parseMessage(msg: any) {
572     let op = wsUserOp(msg);
573     console.log(msg);
574     if (msg.error) {
575       if (msg.error == "not_logged_in") {
576         UserService.Instance.logout();
577       }
578       return;
579     } else if (msg.reconnect) {
580       console.log(i18n.t("websocket_reconnected"));
581       if (UserService.Instance.myUserInfo.isSome()) {
582         WebSocketService.Instance.send(
583           wsClient.userJoin({
584             auth: auth().unwrap(),
585           })
586         );
587         this.fetchUnreads();
588       }
589     } else if (op == UserOperation.GetUnreadCount) {
590       let data = wsJsonToRes<GetUnreadCountResponse>(
591         msg,
592         GetUnreadCountResponse
593       );
594       this.setState({
595         unreadInboxCount: data.replies + data.mentions + data.private_messages,
596       });
597       this.sendUnreadCount();
598     } else if (op == UserOperation.GetReportCount) {
599       let data = wsJsonToRes<GetReportCountResponse>(
600         msg,
601         GetReportCountResponse
602       );
603       this.setState({
604         unreadReportCount:
605           data.post_reports +
606           data.comment_reports +
607           data.private_message_reports.unwrapOr(0),
608       });
609       this.sendReportUnread();
610     } else if (op == UserOperation.GetUnreadRegistrationApplicationCount) {
611       let data = wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(
612         msg,
613         GetUnreadRegistrationApplicationCountResponse
614       );
615       this.setState({ unreadApplicationCount: data.registration_applications });
616       this.sendApplicationUnread();
617     } else if (op == UserOperation.CreateComment) {
618       let data = wsJsonToRes<CommentResponse>(msg, CommentResponse);
619
620       UserService.Instance.myUserInfo.match({
621         some: mui => {
622           if (data.recipient_ids.includes(mui.local_user_view.local_user.id)) {
623             this.setState({
624               unreadInboxCount: this.state.unreadInboxCount + 1,
625             });
626             this.sendUnreadCount();
627             notifyComment(data.comment_view, this.context.router);
628           }
629         },
630         none: void 0,
631       });
632     } else if (op == UserOperation.CreatePrivateMessage) {
633       let data = wsJsonToRes<PrivateMessageResponse>(
634         msg,
635         PrivateMessageResponse
636       );
637
638       UserService.Instance.myUserInfo.match({
639         some: mui => {
640           if (
641             data.private_message_view.recipient.id ==
642             mui.local_user_view.person.id
643           ) {
644             this.setState({
645               unreadInboxCount: this.state.unreadInboxCount + 1,
646             });
647             this.sendUnreadCount();
648             notifyPrivateMessage(
649               data.private_message_view,
650               this.context.router
651             );
652           }
653         },
654         none: void 0,
655       });
656     }
657   }
658
659   fetchUnreads() {
660     console.log("Fetching inbox unreads...");
661
662     let unreadForm = new GetUnreadCount({
663       auth: auth().unwrap(),
664     });
665     WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm));
666
667     console.log("Fetching reports...");
668
669     let reportCountForm = new GetReportCount({
670       community_id: None,
671       auth: auth().unwrap(),
672     });
673     WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
674
675     if (amAdmin()) {
676       console.log("Fetching applications...");
677
678       let applicationCountForm = new GetUnreadRegistrationApplicationCount({
679         auth: auth().unwrap(),
680       });
681       WebSocketService.Instance.send(
682         wsClient.getUnreadRegistrationApplicationCount(applicationCountForm)
683       );
684     }
685   }
686
687   get currentLocation() {
688     return this.context.router.history.location.pathname;
689   }
690
691   sendUnreadCount() {
692     UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
693   }
694
695   sendReportUnread() {
696     UserService.Instance.unreadReportCountSub.next(
697       this.state.unreadReportCount
698     );
699   }
700
701   sendApplicationUnread() {
702     UserService.Instance.unreadApplicationCountSub.next(
703       this.state.unreadApplicationCount
704     );
705   }
706
707   requestNotificationPermission() {
708     if (UserService.Instance.myUserInfo.isSome()) {
709       document.addEventListener("DOMContentLoaded", function () {
710         if (!Notification) {
711           toast(i18n.t("notifications_error"), "danger");
712           return;
713         }
714
715         if (Notification.permission !== "granted")
716           Notification.requestPermission();
717       });
718     }
719   }
720 }