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