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