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