]> Untitled Git - lemmy.git/blob - ui/src/components/navbar.tsx
Adding a lot of front end prettying.
[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="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
154         <div class="container">
155           <Link title={this.state.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 border-0"
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 border-0"
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 my-2 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               <form
233                 class="form-inline"
234                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
235               >
236                 <input
237                   class={`form-control mr-0 search-input ${
238                     this.state.toggleSearch ? 'show-input' : 'hide-input'
239                   }`}
240                   onInput={linkEvent(this, this.handleSearchParam)}
241                   value={this.state.searchParam}
242                   ref={this.searchTextField}
243                   type="text"
244                   placeholder={i18n.t('search')}
245                   onBlur={linkEvent(this, this.handleSearchBlur)}
246                 ></input>
247                 <button
248                   name="search-btn"
249                   onClick={linkEvent(this, this.handleSearchBtn)}
250                   class="btn btn-link"
251                   style="color: var(--gray)"
252                 >
253                   <svg class="icon">
254                     <use xlinkHref="#icon-search"></use>
255                   </svg>
256                 </button>
257               </form>
258             )}
259             <ul class="navbar-nav my-2">
260               {this.canAdmin && (
261                 <li className="nav-item">
262                   <Link
263                     class="nav-link"
264                     to={`/admin`}
265                     title={i18n.t('admin_settings')}
266                   >
267                     <svg class="icon">
268                       <use xlinkHref="#icon-settings"></use>
269                     </svg>
270                   </Link>
271                 </li>
272               )}
273             </ul>
274             {this.state.isLoggedIn ? (
275               <>
276                 <ul class="navbar-nav my-2">
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                 </ul>
290                 <ul class="navbar-nav">
291                   <li className="nav-item">
292                     <Link
293                       class="nav-link"
294                       to={`/u/${UserService.Instance.user.username}`}
295                       title={i18n.t('settings')}
296                     >
297                       <span>
298                         {UserService.Instance.user.avatar && showAvatars() && (
299                           <img
300                             src={pictrsAvatarThumbnail(
301                               UserService.Instance.user.avatar
302                             )}
303                             height="32"
304                             width="32"
305                             class="rounded-circle mr-2"
306                           />
307                         )}
308                         {UserService.Instance.user.username}
309                       </span>
310                     </Link>
311                   </li>
312                 </ul>
313               </>
314             ) : (
315               <ul class="navbar-nav my-2">
316                 <li className="nav-item">
317                   <Link
318                     class="btn btn-success"
319                     to="/login"
320                     title={i18n.t('login_sign_up')}
321                   >
322                     {i18n.t('login_sign_up')}
323                   </Link>
324                 </li>
325               </ul>
326             )}
327           </div>
328         </div>
329       </nav>
330     );
331   }
332
333   expandNavbar(i: Navbar) {
334     i.state.expanded = !i.state.expanded;
335     i.setState(i.state);
336   }
337
338   parseMessage(msg: WebSocketJsonResponse) {
339     let res = wsJsonToRes(msg);
340     if (msg.error) {
341       if (msg.error == 'not_logged_in') {
342         UserService.Instance.logout();
343         location.reload();
344       }
345       return;
346     } else if (msg.reconnect) {
347       this.fetchUnreads();
348     } else if (res.op == UserOperation.GetReplies) {
349       let data = res.data as GetRepliesResponse;
350       let unreadReplies = data.replies.filter(r => !r.read);
351
352       this.state.replies = unreadReplies;
353       this.state.unreadCount = this.calculateUnreadCount();
354       this.setState(this.state);
355       this.sendUnreadCount();
356     } else if (res.op == UserOperation.GetUserMentions) {
357       let data = res.data as GetUserMentionsResponse;
358       let unreadMentions = data.mentions.filter(r => !r.read);
359
360       this.state.mentions = unreadMentions;
361       this.state.unreadCount = this.calculateUnreadCount();
362       this.setState(this.state);
363       this.sendUnreadCount();
364     } else if (res.op == UserOperation.GetPrivateMessages) {
365       let data = res.data as PrivateMessagesResponse;
366       let unreadMessages = data.messages.filter(r => !r.read);
367
368       this.state.messages = unreadMessages;
369       this.state.unreadCount = this.calculateUnreadCount();
370       this.setState(this.state);
371       this.sendUnreadCount();
372     } else if (res.op == UserOperation.CreateComment) {
373       let data = res.data as CommentResponse;
374
375       if (this.state.isLoggedIn) {
376         if (data.recipient_ids.includes(UserService.Instance.user.id)) {
377           this.state.replies.push(data.comment);
378           this.state.unreadCount++;
379           this.setState(this.state);
380           this.sendUnreadCount();
381           this.notify(data.comment);
382         }
383       }
384     } else if (res.op == UserOperation.CreatePrivateMessage) {
385       let data = res.data as PrivateMessageResponse;
386
387       if (this.state.isLoggedIn) {
388         if (data.message.recipient_id == UserService.Instance.user.id) {
389           this.state.messages.push(data.message);
390           this.state.unreadCount++;
391           this.setState(this.state);
392           this.sendUnreadCount();
393           this.notify(data.message);
394         }
395       }
396     } else if (res.op == UserOperation.GetSite) {
397       let data = res.data as GetSiteResponse;
398
399       if (data.site && !this.state.siteName) {
400         this.state.siteName = data.site.name;
401         this.state.version = data.version;
402         this.state.admins = data.admins;
403         this.setState(this.state);
404       }
405     }
406   }
407
408   fetchUnreads() {
409     if (this.state.isLoggedIn) {
410       let repliesForm: GetRepliesForm = {
411         sort: SortType[SortType.New],
412         unread_only: true,
413         page: 1,
414         limit: fetchLimit,
415       };
416
417       let userMentionsForm: GetUserMentionsForm = {
418         sort: SortType[SortType.New],
419         unread_only: true,
420         page: 1,
421         limit: fetchLimit,
422       };
423
424       let privateMessagesForm: GetPrivateMessagesForm = {
425         unread_only: true,
426         page: 1,
427         limit: fetchLimit,
428       };
429
430       if (this.currentLocation !== '/inbox') {
431         WebSocketService.Instance.getReplies(repliesForm);
432         WebSocketService.Instance.getUserMentions(userMentionsForm);
433         WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
434       }
435     }
436   }
437
438   get currentLocation() {
439     return this.context.router.history.location.pathname;
440   }
441
442   sendUnreadCount() {
443     UserService.Instance.user.unreadCount = this.state.unreadCount;
444     UserService.Instance.sub.next({
445       user: UserService.Instance.user,
446     });
447   }
448
449   calculateUnreadCount(): number {
450     return (
451       this.state.replies.filter(r => !r.read).length +
452       this.state.mentions.filter(r => !r.read).length +
453       this.state.messages.filter(r => !r.read).length
454     );
455   }
456
457   get canAdmin(): boolean {
458     return (
459       UserService.Instance.user &&
460       this.state.admins.map(a => a.id).includes(UserService.Instance.user.id)
461     );
462   }
463
464   requestNotificationPermission() {
465     if (UserService.Instance.user) {
466       document.addEventListener('DOMContentLoaded', function () {
467         if (!Notification) {
468           toast(i18n.t('notifications_error'), 'danger');
469           return;
470         }
471
472         if (Notification.permission !== 'granted')
473           Notification.requestPermission();
474       });
475     }
476   }
477
478   notify(reply: Comment | PrivateMessage) {
479     let creator_name = reply.creator_name;
480     let creator_avatar = reply.creator_avatar
481       ? reply.creator_avatar
482       : `${window.location.protocol}//${window.location.host}/static/assets/apple-touch-icon.png`;
483     let link = isCommentType(reply)
484       ? `/post/${reply.post_id}/comment/${reply.id}`
485       : `/inbox`;
486     let htmlBody = md.render(reply.content);
487     let body = reply.content; // Unfortunately the notifications API can't do html
488
489     messageToastify(
490       creator_name,
491       creator_avatar,
492       htmlBody,
493       link,
494       this.context.router
495     );
496
497     if (Notification.permission !== 'granted') Notification.requestPermission();
498     else {
499       var notification = new Notification(reply.creator_name, {
500         icon: creator_avatar,
501         body: body,
502       });
503
504       notification.onclick = () => {
505         event.preventDefault();
506         this.context.router.history.push(link);
507       };
508     }
509   }
510 }