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