]> Untitled Git - lemmy.git/blob - ui/src/components/navbar.tsx
Add a simple linked instances page. Fixes #1070 (#1071)
[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   PrivateMessageResponse,
20   WebSocketJsonResponse,
21 } from '../interfaces';
22 import {
23   wsJsonToRes,
24   pictrsAvatarThumbnail,
25   showAvatars,
26   fetchLimit,
27   isCommentType,
28   toast,
29   messageToastify,
30   md,
31   setTheme,
32   getLanguage,
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   searchParam: string;
44   toggleSearch: boolean;
45   siteLoading: boolean;
46   siteRes: GetSiteResponse;
47   onSiteBanner?(url: string): any;
48 }
49
50 export class Navbar extends Component<any, NavbarState> {
51   private wsSub: Subscription;
52   private userSub: Subscription;
53   private unreadCountSub: Subscription;
54   private searchTextField: RefObject<HTMLInputElement>;
55   emptyState: NavbarState = {
56     isLoggedIn: false,
57     unreadCount: 0,
58     replies: [],
59     mentions: [],
60     messages: [],
61     expanded: false,
62     siteRes: {
63       site: {
64         id: null,
65         name: null,
66         creator_id: null,
67         creator_name: null,
68         published: null,
69         number_of_users: null,
70         number_of_posts: null,
71         number_of_comments: null,
72         number_of_communities: null,
73         enable_downvotes: null,
74         open_registration: null,
75         enable_nsfw: null,
76         icon: null,
77         banner: null,
78         creator_preferred_username: null,
79       },
80       my_user: null,
81       admins: [],
82       banned: [],
83       online: null,
84       version: null,
85       federated_instances: null,
86     },
87     searchParam: '',
88     toggleSearch: false,
89     siteLoading: true,
90   };
91
92   constructor(props: any, context: any) {
93     super(props, context);
94     this.state = this.emptyState;
95
96     this.wsSub = WebSocketService.Instance.subject
97       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
98       .subscribe(
99         msg => this.parseMessage(msg),
100         err => console.error(err),
101         () => console.log('complete')
102       );
103
104     WebSocketService.Instance.getSite();
105
106     this.searchTextField = createRef();
107   }
108
109   componentDidMount() {
110     // Subscribe to jwt changes
111     this.userSub = UserService.Instance.jwtSub.subscribe(res => {
112       // A login
113       if (res !== undefined) {
114         this.requestNotificationPermission();
115       } else {
116         this.state.isLoggedIn = false;
117       }
118       WebSocketService.Instance.getSite();
119       this.setState(this.state);
120     });
121
122     // Subscribe to unread count changes
123     this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(res => {
124       this.setState({ unreadCount: res });
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.state.siteLoading ? (
186             <Link
187               title={this.state.siteRes.version}
188               class="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           ) : (
202             <div class="navbar-item">
203               <svg class="icon icon-spinner spin">
204                 <use xlinkHref="#icon-spinner"></use>
205               </svg>
206             </div>
207           )}
208           {this.state.isLoggedIn && (
209             <Link
210               class="ml-auto p-0 navbar-toggler nav-link border-0"
211               to="/inbox"
212               title={i18n.t('inbox')}
213             >
214               <svg class="icon">
215                 <use xlinkHref="#icon-bell"></use>
216               </svg>
217               {this.state.unreadCount > 0 && (
218                 <span class="mx-1 badge badge-light">
219                   {this.state.unreadCount}
220                 </span>
221               )}
222             </Link>
223           )}
224           <button
225             class="navbar-toggler border-0 p-1"
226             type="button"
227             aria-label="menu"
228             onClick={linkEvent(this, this.expandNavbar)}
229             data-tippy-content={i18n.t('expand_here')}
230           >
231             <span class="navbar-toggler-icon"></span>
232           </button>
233           {!this.state.siteLoading && (
234             <div
235               className={`${
236                 !this.state.expanded && 'collapse'
237               } navbar-collapse`}
238             >
239               <ul class="navbar-nav my-2 mr-auto">
240                 <li class="nav-item">
241                   <Link
242                     class="nav-link"
243                     to="/communities"
244                     title={i18n.t('communities')}
245                   >
246                     {i18n.t('communities')}
247                   </Link>
248                 </li>
249                 <li class="nav-item">
250                   <Link
251                     class="nav-link"
252                     to={{
253                       pathname: '/create_post',
254                       state: { prevPath: this.currentLocation },
255                     }}
256                     title={i18n.t('create_post')}
257                   >
258                     {i18n.t('create_post')}
259                   </Link>
260                 </li>
261                 <li class="nav-item">
262                   <Link
263                     class="nav-link"
264                     to="/create_community"
265                     title={i18n.t('create_community')}
266                   >
267                     {i18n.t('create_community')}
268                   </Link>
269                 </li>
270                 <li className="nav-item">
271                   <Link
272                     class="nav-link"
273                     to="/sponsors"
274                     title={i18n.t('donate_to_lemmy')}
275                   >
276                     <svg class="icon">
277                       <use xlinkHref="#icon-coffee"></use>
278                     </svg>
279                   </Link>
280                 </li>
281               </ul>
282               <ul class="navbar-nav my-2">
283                 {this.canAdmin && (
284                   <li className="nav-item">
285                     <Link
286                       class="nav-link"
287                       to={`/admin`}
288                       title={i18n.t('admin_settings')}
289                     >
290                       <svg class="icon">
291                         <use xlinkHref="#icon-settings"></use>
292                       </svg>
293                     </Link>
294                   </li>
295                 )}
296               </ul>
297               {!this.context.router.history.location.pathname.match(
298                 /^\/search/
299               ) && (
300                 <form
301                   class="form-inline"
302                   onSubmit={linkEvent(this, this.handleSearchSubmit)}
303                 >
304                   <input
305                     class={`form-control mr-0 search-input ${
306                       this.state.toggleSearch ? 'show-input' : 'hide-input'
307                     }`}
308                     onInput={linkEvent(this, this.handleSearchParam)}
309                     value={this.state.searchParam}
310                     ref={this.searchTextField}
311                     type="text"
312                     placeholder={i18n.t('search')}
313                     onBlur={linkEvent(this, this.handleSearchBlur)}
314                   ></input>
315                   <button
316                     name="search-btn"
317                     onClick={linkEvent(this, this.handleSearchBtn)}
318                     class="px-1 btn btn-link"
319                     style="color: var(--gray)"
320                   >
321                     <svg class="icon">
322                       <use xlinkHref="#icon-search"></use>
323                     </svg>
324                   </button>
325                 </form>
326               )}
327               {this.state.isLoggedIn ? (
328                 <>
329                   <ul class="navbar-nav my-2">
330                     <li className="nav-item">
331                       <Link
332                         class="nav-link"
333                         to="/inbox"
334                         title={i18n.t('inbox')}
335                       >
336                         <svg class="icon">
337                           <use xlinkHref="#icon-bell"></use>
338                         </svg>
339                         {this.state.unreadCount > 0 && (
340                           <span class="ml-1 badge badge-light">
341                             {this.state.unreadCount}
342                           </span>
343                         )}
344                       </Link>
345                     </li>
346                   </ul>
347                   <ul class="navbar-nav">
348                     <li className="nav-item">
349                       <Link
350                         class="nav-link"
351                         to={`/u/${user.name}`}
352                         title={i18n.t('settings')}
353                       >
354                         <span>
355                           {user.avatar && showAvatars() && (
356                             <img
357                               src={pictrsAvatarThumbnail(user.avatar)}
358                               height="32"
359                               width="32"
360                               class="rounded-circle mr-2"
361                             />
362                           )}
363                           {user.preferred_username
364                             ? user.preferred_username
365                             : user.name}
366                         </span>
367                       </Link>
368                     </li>
369                   </ul>
370                 </>
371               ) : (
372                 <ul class="navbar-nav my-2">
373                   <li className="ml-2 nav-item">
374                     <Link
375                       class="btn btn-success"
376                       to="/login"
377                       title={i18n.t('login_sign_up')}
378                     >
379                       {i18n.t('login_sign_up')}
380                     </Link>
381                   </li>
382                 </ul>
383               )}
384             </div>
385           )}
386         </div>
387       </nav>
388     );
389   }
390
391   expandNavbar(i: Navbar) {
392     i.state.expanded = !i.state.expanded;
393     i.setState(i.state);
394   }
395
396   parseMessage(msg: WebSocketJsonResponse) {
397     let res = wsJsonToRes(msg);
398     if (msg.error) {
399       if (msg.error == 'not_logged_in') {
400         UserService.Instance.logout();
401         location.reload();
402       }
403       return;
404     } else if (msg.reconnect) {
405       this.fetchUnreads();
406     } else if (res.op == UserOperation.GetReplies) {
407       let data = res.data as GetRepliesResponse;
408       let unreadReplies = data.replies.filter(r => !r.read);
409
410       this.state.replies = unreadReplies;
411       this.state.unreadCount = this.calculateUnreadCount();
412       this.setState(this.state);
413       this.sendUnreadCount();
414     } else if (res.op == UserOperation.GetUserMentions) {
415       let data = res.data as GetUserMentionsResponse;
416       let unreadMentions = data.mentions.filter(r => !r.read);
417
418       this.state.mentions = unreadMentions;
419       this.state.unreadCount = this.calculateUnreadCount();
420       this.setState(this.state);
421       this.sendUnreadCount();
422     } else if (res.op == UserOperation.GetPrivateMessages) {
423       let data = res.data as PrivateMessagesResponse;
424       let unreadMessages = data.messages.filter(r => !r.read);
425
426       this.state.messages = unreadMessages;
427       this.state.unreadCount = this.calculateUnreadCount();
428       this.setState(this.state);
429       this.sendUnreadCount();
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           this.notify(data.comment);
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           this.notify(data.message);
452         }
453       }
454     } else if (res.op == UserOperation.GetSite) {
455       let data = res.data as GetSiteResponse;
456
457       this.state.siteRes = data;
458
459       // The login
460       if (data.my_user) {
461         UserService.Instance.user = data.my_user;
462         WebSocketService.Instance.userJoin();
463         // On the first load, check the unreads
464         if (this.state.isLoggedIn == false) {
465           this.requestNotificationPermission();
466           this.fetchUnreads();
467           setTheme(data.my_user.theme, true);
468           i18n.changeLanguage(getLanguage());
469         }
470         this.state.isLoggedIn = true;
471       }
472
473       this.state.siteLoading = false;
474       this.setState(this.state);
475     }
476   }
477
478   fetchUnreads() {
479     console.log('Fetching unreads...');
480     let repliesForm: GetRepliesForm = {
481       sort: SortType[SortType.New],
482       unread_only: true,
483       page: 1,
484       limit: fetchLimit,
485     };
486
487     let userMentionsForm: GetUserMentionsForm = {
488       sort: SortType[SortType.New],
489       unread_only: true,
490       page: 1,
491       limit: fetchLimit,
492     };
493
494     let privateMessagesForm: GetPrivateMessagesForm = {
495       unread_only: true,
496       page: 1,
497       limit: fetchLimit,
498     };
499
500     if (this.currentLocation !== '/inbox') {
501       WebSocketService.Instance.getReplies(repliesForm);
502       WebSocketService.Instance.getUserMentions(userMentionsForm);
503       WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
504     }
505   }
506
507   get currentLocation() {
508     return this.context.router.history.location.pathname;
509   }
510
511   sendUnreadCount() {
512     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
513   }
514
515   calculateUnreadCount(): number {
516     return (
517       this.state.replies.filter(r => !r.read).length +
518       this.state.mentions.filter(r => !r.read).length +
519       this.state.messages.filter(r => !r.read).length
520     );
521   }
522
523   get canAdmin(): boolean {
524     return (
525       UserService.Instance.user &&
526       this.state.siteRes.admins
527         .map(a => a.id)
528         .includes(UserService.Instance.user.id)
529     );
530   }
531
532   requestNotificationPermission() {
533     if (UserService.Instance.user) {
534       document.addEventListener('DOMContentLoaded', function () {
535         if (!Notification) {
536           toast(i18n.t('notifications_error'), 'danger');
537           return;
538         }
539
540         if (Notification.permission !== 'granted')
541           Notification.requestPermission();
542       });
543     }
544   }
545
546   notify(reply: Comment | PrivateMessage) {
547     let creator_name = reply.creator_name;
548     let creator_avatar = reply.creator_avatar
549       ? reply.creator_avatar
550       : `${window.location.protocol}//${window.location.host}/static/assets/apple-touch-icon.png`;
551     let link = isCommentType(reply)
552       ? `/post/${reply.post_id}/comment/${reply.id}`
553       : `/inbox`;
554     let htmlBody = md.render(reply.content);
555     let body = reply.content; // Unfortunately the notifications API can't do html
556
557     messageToastify(
558       creator_name,
559       creator_avatar,
560       htmlBody,
561       link,
562       this.context.router
563     );
564
565     if (Notification.permission !== 'granted') Notification.requestPermission();
566     else {
567       var notification = new Notification(reply.creator_name, {
568         icon: creator_avatar,
569         body: body,
570       });
571
572       notification.onclick = () => {
573         event.preventDefault();
574         this.context.router.history.push(link);
575       };
576     }
577   }
578 }