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