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