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