]> Untitled Git - lemmy-ui.git/blob - src/shared/components/navbar.tsx
Merge remote-tracking branch 'origin/drone-ci' into drone-ci-dess
[lemmy-ui.git] / src / shared / components / navbar.tsx
1 import { Component, linkEvent, createRef, RefObject } from 'inferno';
2 import { Link } from 'inferno-router';
3 import { Subscription } from 'rxjs';
4 import { WebSocketService, UserService } from '../services';
5 import {
6   UserOperation,
7   GetReplies,
8   GetRepliesResponse,
9   GetUserMentions,
10   GetUserMentionsResponse,
11   GetPrivateMessages,
12   PrivateMessagesResponse,
13   SortType,
14   GetSiteResponse,
15   CommentView,
16   CommentResponse,
17   PrivateMessageResponse,
18   PrivateMessageView,
19 } from 'lemmy-js-client';
20 import {
21   wsJsonToRes,
22   showAvatars,
23   fetchLimit,
24   toast,
25   setTheme,
26   getLanguage,
27   notifyComment,
28   notifyPrivateMessage,
29   isBrowser,
30   wsSubscribe,
31   supportLemmyUrl,
32   wsUserOp,
33   wsClient,
34   authField,
35 } from '../utils';
36 import { i18n } from '../i18next';
37 import { PictrsImage } from './pictrs-image';
38
39 interface NavbarProps {
40   site_res: GetSiteResponse;
41 }
42
43 interface NavbarState {
44   isLoggedIn: boolean;
45   expanded: boolean;
46   replies: CommentView[];
47   mentions: CommentView[];
48   messages: PrivateMessageView[];
49   unreadCount: number;
50   searchParam: string;
51   toggleSearch: boolean;
52   onSiteBanner?(url: string): any;
53 }
54
55 export class Navbar extends Component<NavbarProps, NavbarState> {
56   private wsSub: Subscription;
57   private userSub: Subscription;
58   private unreadCountSub: Subscription;
59   private searchTextField: RefObject<HTMLInputElement>;
60   emptyState: NavbarState = {
61     isLoggedIn: !!this.props.site_res.my_user,
62     unreadCount: 0,
63     replies: [],
64     mentions: [],
65     messages: [],
66     expanded: false,
67     searchParam: '',
68     toggleSearch: false,
69   };
70   subscription: any;
71
72   constructor(props: any, context: any) {
73     super(props, context);
74     this.state = this.emptyState;
75
76     this.parseMessage = this.parseMessage.bind(this);
77     this.subscription = wsSubscribe(this.parseMessage);
78   }
79
80   componentDidMount() {
81     // Subscribe to jwt changes
82     if (isBrowser()) {
83       this.searchTextField = createRef();
84       console.log(`isLoggedIn = ${this.state.isLoggedIn}`);
85
86       // On the first load, check the unreads
87       if (this.state.isLoggedIn == false) {
88         // setTheme(data.my_user.theme, true);
89         // i18n.changeLanguage(getLanguage());
90         // i18n.changeLanguage('de');
91       } else {
92         this.requestNotificationPermission();
93         WebSocketService.Instance.send(
94           wsClient.userJoin({
95             auth: authField(),
96           })
97         );
98         this.fetchUnreads();
99       }
100
101       this.userSub = UserService.Instance.jwtSub.subscribe(res => {
102         // A login
103         if (res !== undefined) {
104           this.requestNotificationPermission();
105           WebSocketService.Instance.send(
106             wsClient.getSite({ auth: authField() })
107           );
108         } else {
109           this.setState({ isLoggedIn: false });
110         }
111       });
112
113       // Subscribe to unread count changes
114       this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(
115         res => {
116           this.setState({ unreadCount: res });
117         }
118       );
119     }
120   }
121
122   handleSearchParam(i: Navbar, event: any) {
123     i.state.searchParam = event.target.value;
124     i.setState(i.state);
125   }
126
127   updateUrl() {
128     const searchParam = this.state.searchParam;
129     this.setState({ searchParam: '' });
130     this.setState({ toggleSearch: false });
131     if (searchParam === '') {
132       this.context.router.history.push(`/search/`);
133     } else {
134       const searchParamEncoded = encodeURIComponent(searchParam);
135       this.context.router.history.push(
136         `/search/q/${searchParamEncoded}/type/All/sort/TopAll/page/1`
137       );
138     }
139   }
140
141   handleSearchSubmit(i: Navbar, event: any) {
142     event.preventDefault();
143     i.updateUrl();
144   }
145
146   handleSearchBtn(i: Navbar, event: any) {
147     event.preventDefault();
148     i.setState({ toggleSearch: true });
149
150     i.searchTextField.current.focus();
151     const offsetWidth = i.searchTextField.current.offsetWidth;
152     if (i.state.searchParam && offsetWidth > 100) {
153       i.updateUrl();
154     }
155   }
156
157   handleSearchBlur(i: Navbar, event: any) {
158     if (!(event.relatedTarget && event.relatedTarget.name !== 'search-btn')) {
159       i.state.toggleSearch = false;
160       i.setState(i.state);
161     }
162   }
163
164   render() {
165     return this.navbar();
166   }
167
168   componentWillUnmount() {
169     this.wsSub.unsubscribe();
170     this.userSub.unsubscribe();
171     this.unreadCountSub.unsubscribe();
172   }
173
174   // TODO class active corresponding to current page
175   navbar() {
176     let user = this.props.site_res.my_user || UserService.Instance.user;
177     return (
178       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
179         <div class="container">
180           {this.props.site_res.site_view && (
181             <Link
182               title={this.props.site_res.version}
183               className="d-flex align-items-center navbar-brand mr-md-3"
184               to="/"
185             >
186               {this.props.site_res.site_view.site.icon && showAvatars() && (
187                 <PictrsImage
188                   src={this.props.site_res.site_view.site.icon}
189                   icon
190                 />
191               )}
192               {this.props.site_res.site_view.site.name}
193             </Link>
194           )}
195           {this.state.isLoggedIn && (
196             <Link
197               className="ml-auto p-1 navbar-toggler nav-link border-0"
198               to="/inbox"
199               title={i18n.t('inbox')}
200             >
201               <svg class="icon">
202                 <use xlinkHref="#icon-bell"></use>
203               </svg>
204               {this.state.unreadCount > 0 && (
205                 <span class="mx-1 badge badge-light">
206                   {this.state.unreadCount}
207                 </span>
208               )}
209             </Link>
210           )}
211           <button
212             class="navbar-toggler border-0 p-1"
213             type="button"
214             aria-label="menu"
215             onClick={linkEvent(this, this.expandNavbar)}
216             data-tippy-content={i18n.t('expand_here')}
217           >
218             <svg class="icon">
219               <use xlinkHref="#icon-menu"></use>
220             </svg>
221           </button>
222           <div
223             className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
224           >
225             <ul class="navbar-nav my-2 mr-auto">
226               <li class="nav-item">
227                 <Link
228                   className="nav-link"
229                   to="/communities"
230                   title={i18n.t('communities')}
231                 >
232                   {i18n.t('communities')}
233                 </Link>
234               </li>
235               <li class="nav-item">
236                 <Link
237                   className="nav-link"
238                   to={{
239                     pathname: '/create_post',
240                     state: { prevPath: this.currentLocation },
241                   }}
242                   title={i18n.t('create_post')}
243                 >
244                   {i18n.t('create_post')}
245                 </Link>
246               </li>
247               <li class="nav-item">
248                 <Link
249                   className="nav-link"
250                   to="/create_community"
251                   title={i18n.t('create_community')}
252                 >
253                   {i18n.t('create_community')}
254                 </Link>
255               </li>
256               <li class="nav-item">
257                 <a
258                   className="nav-link"
259                   title={i18n.t('support_lemmy')}
260                   href={supportLemmyUrl}
261                 >
262                   <svg class="icon small">
263                     <use xlinkHref="#icon-beer"></use>
264                   </svg>
265                 </a>
266               </li>
267             </ul>
268             <ul class="navbar-nav my-2">
269               {this.canAdmin && (
270                 <li className="nav-item">
271                   <Link
272                     className="nav-link"
273                     to={`/admin`}
274                     title={i18n.t('admin_settings')}
275                   >
276                     <svg class="icon">
277                       <use xlinkHref="#icon-settings"></use>
278                     </svg>
279                   </Link>
280                 </li>
281               )}
282             </ul>
283             {!this.context.router.history.location.pathname.match(
284               /^\/search/
285             ) && (
286               <form
287                 class="form-inline"
288                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
289               >
290                 <input
291                   class={`form-control mr-0 search-input ${
292                     this.state.toggleSearch ? 'show-input' : 'hide-input'
293                   }`}
294                   onInput={linkEvent(this, this.handleSearchParam)}
295                   value={this.state.searchParam}
296                   ref={this.searchTextField}
297                   type="text"
298                   placeholder={i18n.t('search')}
299                   onBlur={linkEvent(this, this.handleSearchBlur)}
300                 ></input>
301                 <button
302                   name="search-btn"
303                   onClick={linkEvent(this, this.handleSearchBtn)}
304                   class="px-1 btn btn-link"
305                   style="color: var(--gray)"
306                 >
307                   <svg class="icon">
308                     <use xlinkHref="#icon-search"></use>
309                   </svg>
310                 </button>
311               </form>
312             )}
313             {this.state.isLoggedIn ? (
314               <>
315                 <ul class="navbar-nav my-2">
316                   <li className="nav-item">
317                     <Link
318                       className="nav-link"
319                       to="/inbox"
320                       title={i18n.t('inbox')}
321                     >
322                       <svg class="icon">
323                         <use xlinkHref="#icon-bell"></use>
324                       </svg>
325                       {this.state.unreadCount > 0 && (
326                         <span class="ml-1 badge badge-light">
327                           {this.state.unreadCount}
328                         </span>
329                       )}
330                     </Link>
331                   </li>
332                 </ul>
333                 <ul class="navbar-nav">
334                   <li className="nav-item">
335                     <Link
336                       className="nav-link"
337                       to={`/u/${user.name}`}
338                       title={i18n.t('settings')}
339                     >
340                       <span>
341                         {user.avatar && showAvatars() && (
342                           <PictrsImage src={user.avatar} icon />
343                         )}
344                         {user.preferred_username
345                           ? user.preferred_username
346                           : user.name}
347                       </span>
348                     </Link>
349                   </li>
350                 </ul>
351               </>
352             ) : (
353               <ul class="navbar-nav my-2">
354                 <li className="ml-2 nav-item">
355                   <Link
356                     className="btn btn-success"
357                     to="/login"
358                     title={i18n.t('login_sign_up')}
359                   >
360                     {i18n.t('login_sign_up')}
361                   </Link>
362                 </li>
363               </ul>
364             )}
365           </div>
366         </div>
367       </nav>
368     );
369   }
370
371   expandNavbar(i: Navbar) {
372     i.state.expanded = !i.state.expanded;
373     i.setState(i.state);
374   }
375
376   parseMessage(msg: any) {
377     let op = wsUserOp(msg);
378     if (msg.error) {
379       if (msg.error == 'not_logged_in') {
380         UserService.Instance.logout();
381         location.reload();
382       }
383       return;
384     } else if (msg.reconnect) {
385       WebSocketService.Instance.send(
386         wsClient.userJoin({
387           auth: authField(),
388         })
389       );
390       this.fetchUnreads();
391     } else if (op == UserOperation.GetReplies) {
392       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
393       let unreadReplies = data.replies.filter(r => !r.comment.read);
394
395       this.state.replies = unreadReplies;
396       this.state.unreadCount = this.calculateUnreadCount();
397       this.setState(this.state);
398       this.sendUnreadCount();
399     } else if (op == UserOperation.GetUserMentions) {
400       let data = wsJsonToRes<GetUserMentionsResponse>(msg).data;
401       let unreadMentions = data.mentions.filter(r => !r.comment.read);
402
403       this.state.mentions = unreadMentions;
404       this.state.unreadCount = this.calculateUnreadCount();
405       this.setState(this.state);
406       this.sendUnreadCount();
407     } else if (op == UserOperation.GetPrivateMessages) {
408       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
409       let unreadMessages = data.private_messages.filter(
410         r => !r.private_message.read
411       );
412
413       this.state.messages = unreadMessages;
414       this.state.unreadCount = this.calculateUnreadCount();
415       this.setState(this.state);
416       this.sendUnreadCount();
417     } else if (op == UserOperation.GetSite) {
418       // This is only called on a successful login
419       let data = wsJsonToRes<GetSiteResponse>(msg).data;
420       console.log(data.my_user);
421       UserService.Instance.user = data.my_user;
422       setTheme(UserService.Instance.user.theme);
423       i18n.changeLanguage(getLanguage());
424       this.state.isLoggedIn = true;
425       this.setState(this.state);
426     } else if (op == UserOperation.CreateComment) {
427       let data = wsJsonToRes<CommentResponse>(msg).data;
428
429       if (this.state.isLoggedIn) {
430         if (data.recipient_ids.includes(UserService.Instance.user.id)) {
431           this.state.replies.push(data.comment_view);
432           this.state.unreadCount++;
433           this.setState(this.state);
434           this.sendUnreadCount();
435           notifyComment(data.comment_view, this.context.router);
436         }
437       }
438     } else if (op == UserOperation.CreatePrivateMessage) {
439       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
440
441       if (this.state.isLoggedIn) {
442         if (
443           data.private_message_view.recipient.id == UserService.Instance.user.id
444         ) {
445           this.state.messages.push(data.private_message_view);
446           this.state.unreadCount++;
447           this.setState(this.state);
448           this.sendUnreadCount();
449           notifyPrivateMessage(data.private_message_view, this.context.router);
450         }
451       }
452     }
453   }
454
455   fetchUnreads() {
456     console.log('Fetching unreads...');
457     let repliesForm: GetReplies = {
458       sort: SortType.New,
459       unread_only: true,
460       page: 1,
461       limit: fetchLimit,
462       auth: authField(),
463     };
464
465     let userMentionsForm: GetUserMentions = {
466       sort: SortType.New,
467       unread_only: true,
468       page: 1,
469       limit: fetchLimit,
470       auth: authField(),
471     };
472
473     let privateMessagesForm: GetPrivateMessages = {
474       unread_only: true,
475       page: 1,
476       limit: fetchLimit,
477       auth: authField(),
478     };
479
480     if (this.currentLocation !== '/inbox') {
481       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
482       WebSocketService.Instance.send(
483         wsClient.getUserMentions(userMentionsForm)
484       );
485       WebSocketService.Instance.send(
486         wsClient.getPrivateMessages(privateMessagesForm)
487       );
488     }
489   }
490
491   get currentLocation() {
492     return this.context.router.history.location.pathname;
493   }
494
495   sendUnreadCount() {
496     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
497   }
498
499   calculateUnreadCount(): number {
500     return (
501       this.state.replies.filter(r => !r.comment.read).length +
502       this.state.mentions.filter(r => !r.comment.read).length +
503       this.state.messages.filter(r => !r.private_message.read).length
504     );
505   }
506
507   get canAdmin(): boolean {
508     return (
509       UserService.Instance.user &&
510       this.props.site_res.admins
511         .map(a => a.user.id)
512         .includes(UserService.Instance.user.id)
513     );
514   }
515
516   requestNotificationPermission() {
517     if (UserService.Instance.user) {
518       document.addEventListener('DOMContentLoaded', function () {
519         if (!Notification) {
520           toast(i18n.t('notifications_error'), 'danger');
521           return;
522         }
523
524         if (Notification.permission !== 'granted')
525           Notification.requestPermission();
526       });
527     }
528   }
529 }