]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Fix up post, profile and community forms. Fixes #409 (#423)
[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"
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="ml-2 nav-item">
380                   <button
381                     className="btn btn-success"
382                     onClick={linkEvent(this, this.handleGotoLogin)}
383                     title={i18n.t("login_sign_up")}
384                   >
385                     {i18n.t("login_sign_up")}
386                   </button>
387                 </li>
388               </ul>
389             )}
390           </div>
391         </div>
392       </nav>
393     );
394   }
395
396   expandNavbar(i: Navbar) {
397     i.state.expanded = !i.state.expanded;
398     i.setState(i.state);
399   }
400
401   handleSearchParam(i: Navbar, event: any) {
402     i.state.searchParam = event.target.value;
403     i.setState(i.state);
404   }
405
406   handleSearchSubmit(i: Navbar, event: any) {
407     event.preventDefault();
408     i.updateUrl();
409   }
410
411   handleSearchBtn(i: Navbar, event: any) {
412     event.preventDefault();
413     i.setState({ toggleSearch: true });
414
415     i.searchTextField.current.focus();
416     const offsetWidth = i.searchTextField.current.offsetWidth;
417     if (i.state.searchParam && offsetWidth > 100) {
418       i.updateUrl();
419     }
420   }
421
422   handleSearchBlur(i: Navbar, event: any) {
423     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
424       i.state.toggleSearch = false;
425       i.setState(i.state);
426     }
427   }
428
429   handleLogoutClick(i: Navbar) {
430     i.setState({ showDropdown: false, expanded: false });
431     UserService.Instance.logout();
432     i.context.router.history.push("/");
433     location.reload();
434   }
435
436   handleGotoSettings(i: Navbar) {
437     i.setState({ showDropdown: false, expanded: false });
438     i.context.router.history.push("/settings");
439   }
440
441   handleGotoProfile(i: Navbar) {
442     i.setState({ showDropdown: false, expanded: false });
443     i.context.router.history.push(
444       `/u/${UserService.Instance.myUserInfo.local_user_view.person.name}`
445     );
446   }
447
448   handleGotoCreatePost(i: Navbar) {
449     i.setState({ showDropdown: false, expanded: false });
450     i.context.router.history.push("/create_post", {
451       prevPath: i.currentLocation,
452     });
453   }
454
455   handleGotoCreateCommunity(i: Navbar) {
456     i.setState({ showDropdown: false, expanded: false });
457     i.context.router.history.push(`/create_community`);
458   }
459
460   handleGotoCommunities(i: Navbar) {
461     i.setState({ showDropdown: false, expanded: false });
462     i.context.router.history.push(`/communities`);
463   }
464
465   handleGotoHome(i: Navbar) {
466     i.setState({ showDropdown: false, expanded: false });
467     i.context.router.history.push(`/`);
468   }
469
470   handleGotoInbox(i: Navbar) {
471     i.setState({ showDropdown: false, expanded: false });
472     i.context.router.history.push(`/inbox`);
473   }
474
475   handleGotoAdmin(i: Navbar) {
476     i.setState({ showDropdown: false, expanded: false });
477     i.context.router.history.push(`/admin`);
478   }
479
480   handleGotoLogin(i: Navbar) {
481     i.setState({ showDropdown: false, expanded: false });
482     i.context.router.history.push(`/login`);
483   }
484
485   handleShowDropdown(i: Navbar) {
486     i.state.showDropdown = !i.state.showDropdown;
487     i.setState(i.state);
488   }
489
490   parseMessage(msg: any) {
491     let op = wsUserOp(msg);
492     console.log(msg);
493     if (msg.error) {
494       if (msg.error == "not_logged_in") {
495         UserService.Instance.logout();
496         location.reload();
497       }
498       return;
499     } else if (msg.reconnect) {
500       console.log(i18n.t("websocket_reconnected"));
501       WebSocketService.Instance.send(
502         wsClient.userJoin({
503           auth: authField(),
504         })
505       );
506       this.fetchUnreads();
507     } else if (op == UserOperation.GetReplies) {
508       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
509       let unreadReplies = data.replies.filter(r => !r.comment.read);
510
511       this.state.replies = unreadReplies;
512       this.state.unreadCount = this.calculateUnreadCount();
513       this.setState(this.state);
514       this.sendUnreadCount();
515     } else if (op == UserOperation.GetPersonMentions) {
516       let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data;
517       let unreadMentions = data.mentions.filter(r => !r.comment.read);
518
519       this.state.mentions = unreadMentions;
520       this.state.unreadCount = this.calculateUnreadCount();
521       this.setState(this.state);
522       this.sendUnreadCount();
523     } else if (op == UserOperation.GetPrivateMessages) {
524       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
525       let unreadMessages = data.private_messages.filter(
526         r => !r.private_message.read
527       );
528
529       this.state.messages = unreadMessages;
530       this.state.unreadCount = this.calculateUnreadCount();
531       this.setState(this.state);
532       this.sendUnreadCount();
533     } else if (op == UserOperation.GetSite) {
534       // This is only called on a successful login
535       let data = wsJsonToRes<GetSiteResponse>(msg).data;
536       console.log(data.my_user);
537       UserService.Instance.myUserInfo = data.my_user;
538       setTheme(
539         UserService.Instance.myUserInfo.local_user_view.local_user.theme
540       );
541       i18n.changeLanguage(getLanguage());
542       this.state.isLoggedIn = true;
543       this.setState(this.state);
544     } else if (op == UserOperation.CreateComment) {
545       let data = wsJsonToRes<CommentResponse>(msg).data;
546
547       if (this.state.isLoggedIn) {
548         if (
549           data.recipient_ids.includes(
550             UserService.Instance.myUserInfo.local_user_view.local_user.id
551           )
552         ) {
553           this.state.replies.push(data.comment_view);
554           this.state.unreadCount++;
555           this.setState(this.state);
556           this.sendUnreadCount();
557           notifyComment(data.comment_view, this.context.router);
558         }
559       }
560     } else if (op == UserOperation.CreatePrivateMessage) {
561       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
562
563       if (this.state.isLoggedIn) {
564         if (
565           data.private_message_view.recipient.id ==
566           UserService.Instance.myUserInfo.local_user_view.person.id
567         ) {
568           this.state.messages.push(data.private_message_view);
569           this.state.unreadCount++;
570           this.setState(this.state);
571           this.sendUnreadCount();
572           notifyPrivateMessage(data.private_message_view, this.context.router);
573         }
574       }
575     }
576   }
577
578   fetchUnreads() {
579     console.log("Fetching unreads...");
580     let repliesForm: GetReplies = {
581       sort: SortType.New,
582       unread_only: true,
583       page: 1,
584       limit: fetchLimit,
585       auth: authField(),
586     };
587
588     let personMentionsForm: GetPersonMentions = {
589       sort: SortType.New,
590       unread_only: true,
591       page: 1,
592       limit: fetchLimit,
593       auth: authField(),
594     };
595
596     let privateMessagesForm: GetPrivateMessages = {
597       unread_only: true,
598       page: 1,
599       limit: fetchLimit,
600       auth: authField(),
601     };
602
603     if (this.currentLocation !== "/inbox") {
604       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
605       WebSocketService.Instance.send(
606         wsClient.getPersonMentions(personMentionsForm)
607       );
608       WebSocketService.Instance.send(
609         wsClient.getPrivateMessages(privateMessagesForm)
610       );
611     }
612   }
613
614   get currentLocation() {
615     return this.context.router.history.location.pathname;
616   }
617
618   sendUnreadCount() {
619     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
620   }
621
622   calculateUnreadCount(): number {
623     return (
624       this.state.replies.filter(r => !r.comment.read).length +
625       this.state.mentions.filter(r => !r.comment.read).length +
626       this.state.messages.filter(r => !r.private_message.read).length
627     );
628   }
629
630   get canAdmin(): boolean {
631     return (
632       UserService.Instance.myUserInfo &&
633       this.props.site_res.admins
634         .map(a => a.person.id)
635         .includes(UserService.Instance.myUserInfo.local_user_view.person.id)
636     );
637   }
638
639   get canCreateCommunity(): boolean {
640     let adminOnly =
641       this.props.site_res.site_view?.site.community_creation_admin_only;
642     return !adminOnly || this.canAdmin;
643   }
644
645   /// Listens for some websocket errors
646   websocketEvents() {
647     let msg = i18n.t("websocket_disconnected");
648     WebSocketService.Instance.closeEventListener(() => {
649       console.error(msg);
650     });
651   }
652
653   requestNotificationPermission() {
654     if (UserService.Instance.myUserInfo) {
655       document.addEventListener("DOMContentLoaded", function () {
656         if (!Notification) {
657           toast(i18n.t("notifications_error"), "danger");
658           return;
659         }
660
661         if (Notification.permission !== "granted")
662           Notification.requestPermission();
663       });
664     }
665   }
666 }