]> Untitled Git - lemmy-ui.git/blob - src/shared/components/navbar.tsx
Running newer prettier.
[lemmy-ui.git] / src / shared / components / navbar.tsx
1 import { Component, linkEvent, createRef, RefObject } from "inferno";
2 import { Link } from "inferno-router";
3 import { Subscription } from "rxjs";
4 import { WebSocketService, UserService } from "../services";
5 import {
6   UserOperation,
7   GetReplies,
8   GetRepliesResponse,
9   GetUserMentions,
10   GetUserMentionsResponse,
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   // TODO class active corresponding to current page
176   navbar() {
177     let user = UserService.Instance.user || 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             <Link
183               title={this.props.site_res.version}
184               className="d-flex align-items-center navbar-brand mr-md-3"
185               to="/"
186             >
187               {this.props.site_res.site_view.site.icon && showAvatars() && (
188                 <PictrsImage
189                   src={this.props.site_res.site_view.site.icon}
190                   icon
191                 />
192               )}
193               {this.props.site_res.site_view.site.name}
194             </Link>
195           )}
196           {this.state.isLoggedIn && (
197             <Link
198               className="ml-auto p-1 navbar-toggler nav-link border-0"
199               to="/inbox"
200               title={i18n.t("inbox")}
201             >
202               <Icon icon="bell" />
203               {this.state.unreadCount > 0 && (
204                 <span
205                   class="mx-1 badge badge-light"
206                   aria-label={`${this.state.unreadCount} ${i18n.t(
207                     "unread_messages"
208                   )}`}
209                 >
210                   {this.state.unreadCount}
211                 </span>
212               )}
213             </Link>
214           )}
215           <button
216             class="navbar-toggler border-0 p-1"
217             type="button"
218             aria-label="menu"
219             onClick={linkEvent(this, this.expandNavbar)}
220             data-tippy-content={i18n.t("expand_here")}
221           >
222             <Icon icon="menu" />
223           </button>
224           <div
225             className={`${!this.state.expanded && "collapse"} navbar-collapse`}
226           >
227             <ul class="navbar-nav my-2 mr-auto">
228               <li class="nav-item">
229                 <Link
230                   className="nav-link"
231                   to="/communities"
232                   title={i18n.t("communities")}
233                 >
234                   {i18n.t("communities")}
235                 </Link>
236               </li>
237               <li class="nav-item">
238                 <Link
239                   className="nav-link"
240                   to={{
241                     pathname: "/create_post",
242                     state: { prevPath: this.currentLocation },
243                   }}
244                   title={i18n.t("create_post")}
245                 >
246                   {i18n.t("create_post")}
247                 </Link>
248               </li>
249               <li class="nav-item">
250                 <Link
251                   className="nav-link"
252                   to="/create_community"
253                   title={i18n.t("create_community")}
254                 >
255                   {i18n.t("create_community")}
256                 </Link>
257               </li>
258               <li class="nav-item">
259                 <a
260                   className="nav-link"
261                   title={i18n.t("support_lemmy")}
262                   href={supportLemmyUrl}
263                 >
264                   <Icon icon="beer" classes="small" />
265                 </a>
266               </li>
267             </ul>
268             <ul class="navbar-nav my-2">
269               {this.canAdmin && (
270                 <li className="nav-item">
271                   <Link
272                     className="nav-link"
273                     to={`/admin`}
274                     title={i18n.t("admin_settings")}
275                   >
276                     <Icon icon="settings" />
277                   </Link>
278                 </li>
279               )}
280             </ul>
281             {!this.context.router.history.location.pathname.match(
282               /^\/search/
283             ) && (
284               <form
285                 class="form-inline"
286                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
287               >
288                 <input
289                   id="search-input"
290                   class={`form-control mr-0 search-input ${
291                     this.state.toggleSearch ? "show-input" : "hide-input"
292                   }`}
293                   onInput={linkEvent(this, this.handleSearchParam)}
294                   value={this.state.searchParam}
295                   ref={this.searchTextField}
296                   type="text"
297                   placeholder={i18n.t("search")}
298                   onBlur={linkEvent(this, this.handleSearchBlur)}
299                 ></input>
300                 <label class="sr-only" htmlFor="search-input">
301                   {i18n.t("search")}
302                 </label>
303                 <button
304                   name="search-btn"
305                   onClick={linkEvent(this, this.handleSearchBtn)}
306                   class="px-1 btn btn-link"
307                   style="color: var(--gray)"
308                   aria-label={i18n.t("search")}
309                 >
310                   <Icon icon="search" />
311                 </button>
312               </form>
313             )}
314             {this.state.isLoggedIn ? (
315               <>
316                 <ul class="navbar-nav my-2">
317                   <li className="nav-item">
318                     <Link
319                       className="nav-link"
320                       to="/inbox"
321                       title={i18n.t("inbox")}
322                     >
323                       <Icon icon="bell" />
324                       {this.state.unreadCount > 0 && (
325                         <span
326                           class="ml-1 badge badge-light"
327                           aria-label={`${this.state.unreadCount} ${i18n.t(
328                             "unread_messages"
329                           )}`}
330                         >
331                           {this.state.unreadCount}
332                         </span>
333                       )}
334                     </Link>
335                   </li>
336                 </ul>
337                 <ul class="navbar-nav">
338                   <li className="nav-item">
339                     <Link
340                       className="nav-link"
341                       to={`/u/${user.name}`}
342                       title={i18n.t("settings")}
343                     >
344                       <span>
345                         {user.avatar && showAvatars() && (
346                           <PictrsImage src={user.avatar} icon />
347                         )}
348                         {user.preferred_username
349                           ? user.preferred_username
350                           : user.name}
351                       </span>
352                     </Link>
353                   </li>
354                 </ul>
355               </>
356             ) : (
357               <ul class="navbar-nav my-2">
358                 <li className="ml-2 nav-item">
359                   <Link
360                     className="btn btn-success"
361                     to="/login"
362                     title={i18n.t("login_sign_up")}
363                   >
364                     {i18n.t("login_sign_up")}
365                   </Link>
366                 </li>
367               </ul>
368             )}
369           </div>
370         </div>
371       </nav>
372     );
373   }
374
375   expandNavbar(i: Navbar) {
376     i.state.expanded = !i.state.expanded;
377     i.setState(i.state);
378   }
379
380   parseMessage(msg: any) {
381     let op = wsUserOp(msg);
382     if (msg.error) {
383       if (msg.error == "not_logged_in") {
384         UserService.Instance.logout();
385         location.reload();
386       }
387       return;
388     } else if (msg.reconnect) {
389       WebSocketService.Instance.send(
390         wsClient.userJoin({
391           auth: authField(),
392         })
393       );
394       this.fetchUnreads();
395     } else if (op == UserOperation.GetReplies) {
396       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
397       let unreadReplies = data.replies.filter(r => !r.comment.read);
398
399       this.state.replies = unreadReplies;
400       this.state.unreadCount = this.calculateUnreadCount();
401       this.setState(this.state);
402       this.sendUnreadCount();
403     } else if (op == UserOperation.GetUserMentions) {
404       let data = wsJsonToRes<GetUserMentionsResponse>(msg).data;
405       let unreadMentions = data.mentions.filter(r => !r.comment.read);
406
407       this.state.mentions = unreadMentions;
408       this.state.unreadCount = this.calculateUnreadCount();
409       this.setState(this.state);
410       this.sendUnreadCount();
411     } else if (op == UserOperation.GetPrivateMessages) {
412       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
413       let unreadMessages = data.private_messages.filter(
414         r => !r.private_message.read
415       );
416
417       this.state.messages = unreadMessages;
418       this.state.unreadCount = this.calculateUnreadCount();
419       this.setState(this.state);
420       this.sendUnreadCount();
421     } else if (op == UserOperation.GetSite) {
422       // This is only called on a successful login
423       let data = wsJsonToRes<GetSiteResponse>(msg).data;
424       console.log(data.my_user);
425       UserService.Instance.user = data.my_user;
426       setTheme(UserService.Instance.user.theme);
427       i18n.changeLanguage(getLanguage());
428       this.state.isLoggedIn = true;
429       this.setState(this.state);
430     } else if (op == UserOperation.CreateComment) {
431       let data = wsJsonToRes<CommentResponse>(msg).data;
432
433       if (this.state.isLoggedIn) {
434         if (data.recipient_ids.includes(UserService.Instance.user.id)) {
435           this.state.replies.push(data.comment_view);
436           this.state.unreadCount++;
437           this.setState(this.state);
438           this.sendUnreadCount();
439           notifyComment(data.comment_view, this.context.router);
440         }
441       }
442     } else if (op == UserOperation.CreatePrivateMessage) {
443       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
444
445       if (this.state.isLoggedIn) {
446         if (
447           data.private_message_view.recipient.id == UserService.Instance.user.id
448         ) {
449           this.state.messages.push(data.private_message_view);
450           this.state.unreadCount++;
451           this.setState(this.state);
452           this.sendUnreadCount();
453           notifyPrivateMessage(data.private_message_view, this.context.router);
454         }
455       }
456     }
457   }
458
459   fetchUnreads() {
460     console.log("Fetching unreads...");
461     let repliesForm: GetReplies = {
462       sort: SortType.New,
463       unread_only: true,
464       page: 1,
465       limit: fetchLimit,
466       auth: authField(),
467     };
468
469     let userMentionsForm: GetUserMentions = {
470       sort: SortType.New,
471       unread_only: true,
472       page: 1,
473       limit: fetchLimit,
474       auth: authField(),
475     };
476
477     let privateMessagesForm: GetPrivateMessages = {
478       unread_only: true,
479       page: 1,
480       limit: fetchLimit,
481       auth: authField(),
482     };
483
484     if (this.currentLocation !== "/inbox") {
485       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
486       WebSocketService.Instance.send(
487         wsClient.getUserMentions(userMentionsForm)
488       );
489       WebSocketService.Instance.send(
490         wsClient.getPrivateMessages(privateMessagesForm)
491       );
492     }
493   }
494
495   get currentLocation() {
496     return this.context.router.history.location.pathname;
497   }
498
499   sendUnreadCount() {
500     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
501   }
502
503   calculateUnreadCount(): number {
504     return (
505       this.state.replies.filter(r => !r.comment.read).length +
506       this.state.mentions.filter(r => !r.comment.read).length +
507       this.state.messages.filter(r => !r.private_message.read).length
508     );
509   }
510
511   get canAdmin(): boolean {
512     return (
513       UserService.Instance.user &&
514       this.props.site_res.admins
515         .map(a => a.user.id)
516         .includes(UserService.Instance.user.id)
517     );
518   }
519
520   requestNotificationPermission() {
521     if (UserService.Instance.user) {
522       document.addEventListener("DOMContentLoaded", function () {
523         if (!Notification) {
524           toast(i18n.t("notifications_error"), "danger");
525           return;
526         }
527
528         if (Notification.permission !== "granted")
529           Notification.requestPermission();
530       });
531     }
532   }
533 }