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