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