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