]> Untitled Git - lemmy-ui.git/blob - src/shared/components/navbar.tsx
877cdd8e58d2dfa4df3d2abab8b4a461b81d50e7
[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       this.context.router.history.push(
141         `/search/q/${searchParam}/type/All/sort/TopAll/page/1`
142       );
143     }
144   }
145
146   handleSearchSubmit(i: Navbar, event: any) {
147     event.preventDefault();
148     i.updateUrl();
149   }
150
151   handleSearchBtn(i: Navbar, event: any) {
152     event.preventDefault();
153     i.setState({ toggleSearch: true });
154
155     i.searchTextField.current.focus();
156     const offsetWidth = i.searchTextField.current.offsetWidth;
157     if (i.state.searchParam && offsetWidth > 100) {
158       i.updateUrl();
159     }
160   }
161
162   handleSearchBlur(i: Navbar, event: any) {
163     if (!(event.relatedTarget && event.relatedTarget.name !== 'search-btn')) {
164       i.state.toggleSearch = false;
165       i.setState(i.state);
166     }
167   }
168
169   render() {
170     return this.navbar();
171   }
172
173   componentWillUnmount() {
174     this.wsSub.unsubscribe();
175     this.userSub.unsubscribe();
176     this.unreadCountSub.unsubscribe();
177   }
178
179   // TODO class active corresponding to current page
180   navbar() {
181     let user = UserService.Instance.user;
182     return (
183       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
184         <div class="container">
185           {this.props.site.site && (
186             <Link
187               title={this.props.site.version}
188               className="d-flex align-items-center navbar-brand mr-md-3"
189               to="/"
190             >
191               {this.props.site.site.icon && showAvatars() && (
192                 <img
193                   src={pictrsAvatarThumbnail(this.props.site.site.icon)}
194                   height="32"
195                   width="32"
196                   class="rounded-circle mr-2"
197                 />
198               )}
199               {this.props.site.site.name}
200             </Link>
201           )}
202           {this.state.isLoggedIn && (
203             <Link
204               className="ml-auto p-0 navbar-toggler nav-link border-0"
205               to="/inbox"
206               title={i18n.t('inbox')}
207             >
208               <svg class="icon">
209                 <use xlinkHref="#icon-bell"></use>
210               </svg>
211               {this.state.unreadCount > 0 && (
212                 <span class="mx-1 badge badge-light">
213                   {this.state.unreadCount}
214                 </span>
215               )}
216             </Link>
217           )}
218           <button
219             class="navbar-toggler border-0 p-1"
220             type="button"
221             aria-label="menu"
222             onClick={linkEvent(this, this.expandNavbar)}
223             data-tippy-content={i18n.t('expand_here')}
224           >
225             <span class="navbar-toggler-icon"></span>
226           </button>
227           <div
228             className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
229           >
230             <ul class="navbar-nav my-2 mr-auto">
231               <li class="nav-item">
232                 <Link
233                   className="nav-link"
234                   to="/communities"
235                   title={i18n.t('communities')}
236                 >
237                   {i18n.t('communities')}
238                 </Link>
239               </li>
240               <li class="nav-item">
241                 <Link
242                   className="nav-link"
243                   to={{
244                     pathname: '/create_post',
245                     state: { prevPath: this.currentLocation },
246                   }}
247                   title={i18n.t('create_post')}
248                 >
249                   {i18n.t('create_post')}
250                 </Link>
251               </li>
252               <li class="nav-item">
253                 <Link
254                   className="nav-link"
255                   to="/create_community"
256                   title={i18n.t('create_community')}
257                 >
258                   {i18n.t('create_community')}
259                 </Link>
260               </li>
261               <li className="nav-item">
262                 <Link
263                   className="nav-link"
264                   to="/sponsors"
265                   title={i18n.t('donate_to_lemmy')}
266                 >
267                   <svg class="icon">
268                     <use xlinkHref="#icon-coffee"></use>
269                   </svg>
270                 </Link>
271               </li>
272             </ul>
273             <ul class="navbar-nav my-2">
274               {this.canAdmin && (
275                 <li className="nav-item">
276                   <Link
277                     className="nav-link"
278                     to={`/admin`}
279                     title={i18n.t('admin_settings')}
280                   >
281                     <svg class="icon">
282                       <use xlinkHref="#icon-settings"></use>
283                     </svg>
284                   </Link>
285                 </li>
286               )}
287             </ul>
288             {!this.context.router.history.location.pathname.match(
289               /^\/search/
290             ) && (
291               <form
292                 class="form-inline"
293                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
294               >
295                 <input
296                   class={`form-control mr-0 search-input ${
297                     this.state.toggleSearch ? 'show-input' : 'hide-input'
298                   }`}
299                   onInput={linkEvent(this, this.handleSearchParam)}
300                   value={this.state.searchParam}
301                   ref={this.searchTextField}
302                   type="text"
303                   placeholder={i18n.t('search')}
304                   onBlur={linkEvent(this, this.handleSearchBlur)}
305                 ></input>
306                 <button
307                   name="search-btn"
308                   onClick={linkEvent(this, this.handleSearchBtn)}
309                   class="px-1 btn btn-link"
310                   style="color: var(--gray)"
311                 >
312                   <svg class="icon">
313                     <use xlinkHref="#icon-search"></use>
314                   </svg>
315                 </button>
316               </form>
317             )}
318             {this.state.isLoggedIn ? (
319               <>
320                 <ul class="navbar-nav my-2">
321                   <li className="nav-item">
322                     <Link
323                       className="nav-link"
324                       to="/inbox"
325                       title={i18n.t('inbox')}
326                     >
327                       <svg class="icon">
328                         <use xlinkHref="#icon-bell"></use>
329                       </svg>
330                       {this.state.unreadCount > 0 && (
331                         <span class="ml-1 badge badge-light">
332                           {this.state.unreadCount}
333                         </span>
334                       )}
335                     </Link>
336                   </li>
337                 </ul>
338                 <ul class="navbar-nav">
339                   <li className="nav-item">
340                     <Link
341                       className="nav-link"
342                       to={`/u/${user.name}`}
343                       title={i18n.t('settings')}
344                     >
345                       <span>
346                         {user.avatar && showAvatars() && (
347                           <img
348                             src={pictrsAvatarThumbnail(user.avatar)}
349                             height="32"
350                             width="32"
351                             class="rounded-circle mr-2"
352                           />
353                         )}
354                         {user.preferred_username
355                           ? user.preferred_username
356                           : user.name}
357                       </span>
358                     </Link>
359                   </li>
360                 </ul>
361               </>
362             ) : (
363               <ul class="navbar-nav my-2">
364                 <li className="ml-2 nav-item">
365                   <Link
366                     className="btn btn-success"
367                     to="/login"
368                     title={i18n.t('login_sign_up')}
369                   >
370                     {i18n.t('login_sign_up')}
371                   </Link>
372                 </li>
373               </ul>
374             )}
375           </div>
376         </div>
377       </nav>
378     );
379   }
380
381   expandNavbar(i: Navbar) {
382     i.state.expanded = !i.state.expanded;
383     i.setState(i.state);
384   }
385
386   parseMessage(msg: WebSocketJsonResponse) {
387     let res = wsJsonToRes(msg);
388     if (msg.error) {
389       if (msg.error == 'not_logged_in') {
390         UserService.Instance.logout();
391         location.reload();
392       }
393       return;
394     } else if (msg.reconnect) {
395       WebSocketService.Instance.userJoin();
396       this.fetchUnreads();
397     } else if (res.op == UserOperation.GetReplies) {
398       let data = res.data as GetRepliesResponse;
399       let unreadReplies = data.replies.filter(r => !r.read);
400
401       this.state.replies = unreadReplies;
402       this.state.unreadCount = this.calculateUnreadCount();
403       this.setState(this.state);
404       this.sendUnreadCount();
405     } else if (res.op == UserOperation.GetUserMentions) {
406       let data = res.data as GetUserMentionsResponse;
407       let unreadMentions = data.mentions.filter(r => !r.read);
408
409       this.state.mentions = unreadMentions;
410       this.state.unreadCount = this.calculateUnreadCount();
411       this.setState(this.state);
412       this.sendUnreadCount();
413     } else if (res.op == UserOperation.GetPrivateMessages) {
414       let data = res.data as PrivateMessagesResponse;
415       let unreadMessages = data.messages.filter(r => !r.read);
416
417       this.state.messages = unreadMessages;
418       this.state.unreadCount = this.calculateUnreadCount();
419       this.setState(this.state);
420       this.sendUnreadCount();
421     } else if (res.op == UserOperation.GetSite) {
422       // This is only called on a successful login
423       let data = res.data as GetSiteResponse;
424       UserService.Instance.user = data.my_user;
425       setTheme(UserService.Instance.user.theme);
426       i18n.changeLanguage(getLanguage());
427       this.state.isLoggedIn = true;
428       this.setState(this.state);
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     }
454   }
455
456   fetchUnreads() {
457     console.log('Fetching unreads...');
458     let repliesForm: GetRepliesForm = {
459       sort: SortType.New,
460       unread_only: true,
461       page: 1,
462       limit: fetchLimit,
463     };
464
465     let userMentionsForm: GetUserMentionsForm = {
466       sort: SortType.New,
467       unread_only: true,
468       page: 1,
469       limit: fetchLimit,
470     };
471
472     let privateMessagesForm: GetPrivateMessagesForm = {
473       unread_only: true,
474       page: 1,
475       limit: fetchLimit,
476     };
477
478     if (this.currentLocation !== '/inbox') {
479       WebSocketService.Instance.getReplies(repliesForm);
480       WebSocketService.Instance.getUserMentions(userMentionsForm);
481       WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
482     }
483   }
484
485   get currentLocation() {
486     return this.context.router.history.location.pathname;
487   }
488
489   sendUnreadCount() {
490     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
491   }
492
493   calculateUnreadCount(): number {
494     return (
495       this.state.replies.filter(r => !r.read).length +
496       this.state.mentions.filter(r => !r.read).length +
497       this.state.messages.filter(r => !r.read).length
498     );
499   }
500
501   get canAdmin(): boolean {
502     return (
503       UserService.Instance.user &&
504       this.props.site.admins
505         .map(a => a.id)
506         .includes(UserService.Instance.user.id)
507     );
508   }
509
510   requestNotificationPermission() {
511     if (UserService.Instance.user) {
512       document.addEventListener('DOMContentLoaded', function () {
513         if (!Notification) {
514           toast(i18n.t('notifications_error'), 'danger');
515           return;
516         }
517
518         if (Notification.permission !== 'granted')
519           Notification.requestPermission();
520       });
521     }
522   }
523 }