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