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