]> Untitled Git - lemmy-ui.git/blob - src/shared/components/navbar.tsx
Change from using Link to NavLink. resolve #269
[lemmy-ui.git] / src / shared / components / navbar.tsx
1 import { Component, linkEvent, createRef, RefObject } from "inferno";
2 import { NavLink } from "inferno-router";
3 import { Subscription } from "rxjs";
4 import { WebSocketService, UserService } from "../services";
5 import {
6   UserOperation,
7   GetReplies,
8   GetRepliesResponse,
9   GetPersonMentions,
10   GetPersonMentionsResponse,
11   GetPrivateMessages,
12   PrivateMessagesResponse,
13   SortType,
14   GetSiteResponse,
15   CommentView,
16   CommentResponse,
17   PrivateMessageResponse,
18   PrivateMessageView,
19 } from "lemmy-js-client";
20 import {
21   wsJsonToRes,
22   showAvatars,
23   fetchLimit,
24   toast,
25   setTheme,
26   getLanguage,
27   notifyComment,
28   notifyPrivateMessage,
29   isBrowser,
30   wsSubscribe,
31   supportLemmyUrl,
32   wsUserOp,
33   wsClient,
34   authField,
35 } from "../utils";
36 import { i18n } from "../i18next";
37 import { PictrsImage } from "./pictrs-image";
38 import { Icon } from "./icon";
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   onSiteBanner?(url: string): any;
54 }
55
56 export class Navbar extends Component<NavbarProps, NavbarState> {
57   private wsSub: Subscription;
58   private userSub: Subscription;
59   private unreadCountSub: Subscription;
60   private searchTextField: RefObject<HTMLInputElement>;
61   emptyState: NavbarState = {
62     isLoggedIn: !!this.props.site_res.my_user,
63     unreadCount: 0,
64     replies: [],
65     mentions: [],
66     messages: [],
67     expanded: false,
68     searchParam: "",
69     toggleSearch: false,
70   };
71   subscription: any;
72
73   constructor(props: any, context: any) {
74     super(props, context);
75     this.state = this.emptyState;
76
77     this.parseMessage = this.parseMessage.bind(this);
78     this.subscription = wsSubscribe(this.parseMessage);
79   }
80
81   componentDidMount() {
82     // Subscribe to jwt changes
83     if (isBrowser()) {
84       this.searchTextField = createRef();
85       console.log(`isLoggedIn = ${this.state.isLoggedIn}`);
86
87       // On the first load, check the unreads
88       if (this.state.isLoggedIn == false) {
89         // setTheme(data.my_user.theme, true);
90         // i18n.changeLanguage(getLanguage());
91         // i18n.changeLanguage('de');
92       } else {
93         this.requestNotificationPermission();
94         WebSocketService.Instance.send(
95           wsClient.userJoin({
96             auth: authField(),
97           })
98         );
99         this.fetchUnreads();
100       }
101
102       this.userSub = UserService.Instance.jwtSub.subscribe(res => {
103         // A login
104         if (res !== undefined) {
105           this.requestNotificationPermission();
106           WebSocketService.Instance.send(
107             wsClient.getSite({ auth: authField() })
108           );
109         } else {
110           this.setState({ isLoggedIn: false });
111         }
112       });
113
114       // Subscribe to unread count changes
115       this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(
116         res => {
117           this.setState({ unreadCount: res });
118         }
119       );
120     }
121   }
122
123   handleSearchParam(i: Navbar, event: any) {
124     i.state.searchParam = event.target.value;
125     i.setState(i.state);
126   }
127
128   updateUrl() {
129     const searchParam = this.state.searchParam;
130     this.setState({ searchParam: "" });
131     this.setState({ toggleSearch: false });
132     if (searchParam === "") {
133       this.context.router.history.push(`/search/`);
134     } else {
135       const searchParamEncoded = encodeURIComponent(searchParam);
136       this.context.router.history.push(
137         `/search/q/${searchParamEncoded}/type/All/sort/TopAll/page/1`
138       );
139     }
140   }
141
142   handleSearchSubmit(i: Navbar, event: any) {
143     event.preventDefault();
144     i.updateUrl();
145   }
146
147   handleSearchBtn(i: Navbar, event: any) {
148     event.preventDefault();
149     i.setState({ toggleSearch: true });
150
151     i.searchTextField.current.focus();
152     const offsetWidth = i.searchTextField.current.offsetWidth;
153     if (i.state.searchParam && offsetWidth > 100) {
154       i.updateUrl();
155     }
156   }
157
158   handleSearchBlur(i: Navbar, event: any) {
159     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
160       i.state.toggleSearch = false;
161       i.setState(i.state);
162     }
163   }
164
165   render() {
166     return this.navbar();
167   }
168
169   componentWillUnmount() {
170     this.wsSub.unsubscribe();
171     this.userSub.unsubscribe();
172     this.unreadCountSub.unsubscribe();
173   }
174   
175   navbar() {
176     let localUserView =
177       UserService.Instance.localUserView || this.props.site_res.my_user;
178     return (
179       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
180         <div class="container">
181           {this.props.site_res.site_view && (
182             <NavLink
183               title={this.props.site_res.version}
184               className="d-flex align-items-center navbar-brand mr-md-3"
185               to="/"
186               exact={true}
187             >
188               {this.props.site_res.site_view.site.icon && showAvatars() && (
189                 <PictrsImage
190                   src={this.props.site_res.site_view.site.icon}
191                   icon
192                 />
193               )}
194               {this.props.site_res.site_view.site.name}
195             </NavLink>
196           )}
197           {this.state.isLoggedIn && (
198             <NavLink
199               className="ml-auto p-1 navbar-toggler nav-link border-0"
200               to="/inbox"
201               title={i18n.t("inbox")}
202             >
203               <Icon icon="bell" />
204               {this.state.unreadCount > 0 && (
205                 <span
206                   class="mx-1 badge badge-light"
207                   aria-label={`${this.state.unreadCount} ${i18n.t(
208                     "unread_messages"
209                   )}`}
210                 >
211                   {this.state.unreadCount}
212                 </span>
213               )}
214             </NavLink>
215           )}
216           <button
217             class="navbar-toggler border-0 p-1"
218             type="button"
219             aria-label="menu"
220             onClick={linkEvent(this, this.expandNavbar)}
221             data-tippy-content={i18n.t("expand_here")}
222           >
223             <Icon icon="menu" />
224           </button>
225           <div
226             className={`${!this.state.expanded && "collapse"} navbar-collapse`}
227           >
228             <ul class="navbar-nav my-2 mr-auto">
229               <li class="nav-item">
230                 <NavLink
231                   className="nav-link"
232                   to="/communities"
233                   title={i18n.t("communities")}
234                 >
235                   {i18n.t("communities")}
236                 </NavLink>
237               </li>
238               <li class="nav-item">
239                 <NavLink
240                   className="nav-link"
241                   to={{
242                     pathname: "/create_post",
243                     state: { prevPath: this.currentLocation },
244                   }}
245                   title={i18n.t("create_post")}
246                 >
247                   {i18n.t("create_post")}
248                 </NavLink>
249               </li>
250               <li class="nav-item">
251                 <NavLink
252                   className="nav-link"
253                   to="/create_community"
254                   title={i18n.t("create_community")}
255                 >
256                   {i18n.t("create_community")}
257                 </NavLink>
258               </li>
259               <li class="nav-item">
260                 <a
261                   className="nav-link"
262                   title={i18n.t("support_lemmy")}
263                   href={supportLemmyUrl}
264                 >
265                   <Icon icon="heart" classes="small" />
266                 </a>
267               </li>
268             </ul>
269             <ul class="navbar-nav my-2">
270               {this.canAdmin && (
271                 <li className="nav-item">
272                   <NavLink
273                     className="nav-link"
274                     to={`/admin`}
275                     title={i18n.t("admin_settings")}
276                   >
277                     <Icon icon="settings" />
278                   </NavLink>
279                 </li>
280               )}
281             </ul>
282             {!this.context.router.history.location.pathname.match(
283               /^\/search/
284             ) && (
285               <form
286                 class="form-inline"
287                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
288               >
289                 <input
290                   id="search-input"
291                   class={`form-control mr-0 search-input ${
292                       this.state.toggleSearch ? "show-input" : "hide-input"
293                   }`}
294                   onInput={linkEvent(this, this.handleSearchParam)}
295                   value={this.state.searchParam}
296                   ref={this.searchTextField}
297                   type="text"
298                   placeholder={i18n.t("search")}
299                   onBlur={linkEvent(this, this.handleSearchBlur)}
300                   />
301                 <label class="sr-only" htmlFor="search-input">
302                   {i18n.t("search")}
303                 </label>
304                 <button
305                   name="search-btn"
306                   onClick={linkEvent(this, this.handleSearchBtn)}
307                   class="px-1 btn btn-link"
308                   style="color: var(--gray)"
309                   aria-label={i18n.t("search")}
310                 >
311                   <Icon icon="search" />
312                 </button>
313               </form>
314             )}
315             {this.state.isLoggedIn ? (
316               <>
317                 <ul class="navbar-nav my-2">
318                   <li className="nav-item">
319                     <NavLink
320                       className="nav-link"
321                       to="/inbox"
322                       title={i18n.t("inbox")}
323                     >
324                       <Icon icon="bell" />
325                       {this.state.unreadCount > 0 && (
326                         <span
327                           class="ml-1 badge badge-light"
328                           aria-label={`${this.state.unreadCount} ${i18n.t(
329                             "unread_messages"
330                           )}`}
331                         >
332                           {this.state.unreadCount}
333                         </span>
334                       )}
335                     </NavLink>
336                   </li>
337                 </ul>
338                 <ul class="navbar-nav">
339                   <li className="nav-item">
340                     <NavLink
341                       className="nav-link"
342                       to={`/u/${localUserView.person.name}`}
343                       title={i18n.t("settings")}
344                     >
345                       <span>
346                         {localUserView.person.avatar && showAvatars() && (
347                           <PictrsImage src={localUserView.person.avatar} icon />
348                         )}
349                         {localUserView.person.display_name
350                           ? localUserView.person.display_name
351                           : localUserView.person.name}
352                       </span>
353                     </NavLink>
354                   </li>
355                 </ul>
356               </>
357             ) : (
358               <ul class="navbar-nav my-2">
359                 <li className="ml-2 nav-item">
360                   <NavLink
361                     className="btn btn-success"
362                     to="/login"
363                     title={i18n.t("login_sign_up")}
364                   >
365                     {i18n.t("login_sign_up")}
366                   </NavLink>
367                 </li>
368               </ul>
369             )}
370           </div>
371         </div>
372       </nav>
373     );
374   }
375
376   expandNavbar(i: Navbar) {
377     i.state.expanded = !i.state.expanded;
378     i.setState(i.state);
379   }
380
381   parseMessage(msg: any) {
382     let op = wsUserOp(msg);
383     console.log(msg);
384     if (msg.error) {
385       if (msg.error == "not_logged_in") {
386         UserService.Instance.logout();
387         location.reload();
388       }
389       return;
390     } else if (msg.reconnect) {
391       WebSocketService.Instance.send(
392         wsClient.userJoin({
393           auth: authField(),
394         })
395       );
396       this.fetchUnreads();
397     } else if (op == UserOperation.GetReplies) {
398       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
399       let unreadReplies = data.replies.filter(r => !r.comment.read);
400
401       this.state.replies = unreadReplies;
402       this.state.unreadCount = this.calculateUnreadCount();
403       this.setState(this.state);
404       this.sendUnreadCount();
405     } else if (op == UserOperation.GetPersonMentions) {
406       let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data;
407       let unreadMentions = data.mentions.filter(r => !r.comment.read);
408
409       this.state.mentions = unreadMentions;
410       this.state.unreadCount = this.calculateUnreadCount();
411       this.setState(this.state);
412       this.sendUnreadCount();
413     } else if (op == UserOperation.GetPrivateMessages) {
414       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
415       let unreadMessages = data.private_messages.filter(
416         r => !r.private_message.read
417       );
418
419       this.state.messages = unreadMessages;
420       this.state.unreadCount = this.calculateUnreadCount();
421       this.setState(this.state);
422       this.sendUnreadCount();
423     } else if (op == UserOperation.GetSite) {
424       // This is only called on a successful login
425       let data = wsJsonToRes<GetSiteResponse>(msg).data;
426       console.log(data.my_user);
427       UserService.Instance.localUserView = data.my_user;
428       setTheme(UserService.Instance.localUserView.local_user.theme);
429       i18n.changeLanguage(getLanguage());
430       this.state.isLoggedIn = true;
431       this.setState(this.state);
432     } else if (op == UserOperation.CreateComment) {
433       let data = wsJsonToRes<CommentResponse>(msg).data;
434
435       if (this.state.isLoggedIn) {
436         if (
437           data.recipient_ids.includes(
438             UserService.Instance.localUserView.local_user.id
439           )
440         ) {
441           this.state.replies.push(data.comment_view);
442           this.state.unreadCount++;
443           this.setState(this.state);
444           this.sendUnreadCount();
445           notifyComment(data.comment_view, this.context.router);
446         }
447       }
448     } else if (op == UserOperation.CreatePrivateMessage) {
449       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
450
451       if (this.state.isLoggedIn) {
452         if (
453           data.private_message_view.recipient.id ==
454           UserService.Instance.localUserView.person.id
455         ) {
456           this.state.messages.push(data.private_message_view);
457           this.state.unreadCount++;
458           this.setState(this.state);
459           this.sendUnreadCount();
460           notifyPrivateMessage(data.private_message_view, this.context.router);
461         }
462       }
463     }
464   }
465
466   fetchUnreads() {
467     console.log("Fetching unreads...");
468     let repliesForm: GetReplies = {
469       sort: SortType.New,
470       unread_only: true,
471       page: 1,
472       limit: fetchLimit,
473       auth: authField(),
474     };
475
476     let personMentionsForm: GetPersonMentions = {
477       sort: SortType.New,
478       unread_only: true,
479       page: 1,
480       limit: fetchLimit,
481       auth: authField(),
482     };
483
484     let privateMessagesForm: GetPrivateMessages = {
485       unread_only: true,
486       page: 1,
487       limit: fetchLimit,
488       auth: authField(),
489     };
490
491     if (this.currentLocation !== "/inbox") {
492       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
493       WebSocketService.Instance.send(
494         wsClient.getPersonMentions(personMentionsForm)
495       );
496       WebSocketService.Instance.send(
497         wsClient.getPrivateMessages(privateMessagesForm)
498       );
499     }
500   }
501
502   get currentLocation() {
503     return this.context.router.history.location.pathname;
504   }
505
506   sendUnreadCount() {
507     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
508   }
509
510   calculateUnreadCount(): number {
511     return (
512       this.state.replies.filter(r => !r.comment.read).length +
513       this.state.mentions.filter(r => !r.comment.read).length +
514       this.state.messages.filter(r => !r.private_message.read).length
515     );
516   }
517
518   get canAdmin(): boolean {
519     return (
520       UserService.Instance.localUserView &&
521       this.props.site_res.admins
522         .map(a => a.person.id)
523         .includes(UserService.Instance.localUserView.person.id)
524     );
525   }
526
527   requestNotificationPermission() {
528     if (UserService.Instance.localUserView) {
529       document.addEventListener("DOMContentLoaded", function () {
530         if (!Notification) {
531           toast(i18n.t("notifications_error"), "danger");
532           return;
533         }
534
535         if (Notification.permission !== "granted")
536           Notification.requestPermission();
537       });
538     }
539   }
540 }