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