]> Untitled Git - lemmy.git/blob - ui/src/components/navbar.tsx
Better tippy loading. Fixes #577
[lemmy.git] / ui / src / components / navbar.tsx
1 import { Component, linkEvent } 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   PrivateMessageResponse,
20   WebSocketJsonResponse,
21 } from '../interfaces';
22 import {
23   wsJsonToRes,
24   pictshareAvatarThumbnail,
25   showAvatars,
26   fetchLimit,
27   isCommentType,
28   toast,
29 } from '../utils';
30 import { version } from '../version';
31 import { i18n } from '../i18next';
32
33 interface NavbarState {
34   isLoggedIn: boolean;
35   expanded: boolean;
36   replies: Array<Comment>;
37   mentions: Array<Comment>;
38   messages: Array<PrivateMessage>;
39   unreadCount: number;
40   siteName: string;
41 }
42
43 export class Navbar extends Component<any, NavbarState> {
44   private wsSub: Subscription;
45   private userSub: Subscription;
46   emptyState: NavbarState = {
47     isLoggedIn: UserService.Instance.user !== undefined,
48     unreadCount: 0,
49     replies: [],
50     mentions: [],
51     messages: [],
52     expanded: false,
53     siteName: undefined,
54   };
55
56   constructor(props: any, context: any) {
57     super(props, context);
58     this.state = this.emptyState;
59
60     // Subscribe to user changes
61     this.userSub = UserService.Instance.sub.subscribe(user => {
62       this.state.isLoggedIn = user.user !== undefined;
63       if (this.state.isLoggedIn) {
64         this.state.unreadCount = user.user.unreadCount;
65         this.requestNotificationPermission();
66       }
67       this.setState(this.state);
68     });
69
70     this.wsSub = WebSocketService.Instance.subject
71       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
72       .subscribe(
73         msg => this.parseMessage(msg),
74         err => console.error(err),
75         () => console.log('complete')
76       );
77
78     if (this.state.isLoggedIn) {
79       this.requestNotificationPermission();
80       // TODO couldn't get re-logging in to re-fetch unreads
81       this.fetchUnreads();
82     }
83
84     WebSocketService.Instance.getSite();
85   }
86
87   render() {
88     return this.navbar();
89   }
90
91   componentWillUnmount() {
92     this.wsSub.unsubscribe();
93     this.userSub.unsubscribe();
94   }
95
96   // TODO class active corresponding to current page
97   navbar() {
98     return (
99       <nav class="container-fluid navbar navbar-expand-md navbar-light shadow p-0 px-3">
100         <Link title={version} class="navbar-brand" to="/">
101           {this.state.siteName}
102         </Link>
103         <button
104           class="navbar-toggler"
105           type="button"
106           aria-label="menu"
107           onClick={linkEvent(this, this.expandNavbar)}
108           data-tippy-content={i18n.t('expand_here')}
109         >
110           <span class="navbar-toggler-icon"></span>
111         </button>
112         <div
113           className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
114         >
115           <ul class="navbar-nav mr-auto">
116             <li class="nav-item">
117               <Link
118                 class="nav-link"
119                 to="/communities"
120                 title={i18n.t('communities')}
121               >
122                 {i18n.t('communities')}
123               </Link>
124             </li>
125             <li class="nav-item">
126               <Link class="nav-link" to="/search" title={i18n.t('search')}>
127                 {i18n.t('search')}
128               </Link>
129             </li>
130             <li class="nav-item">
131               <Link
132                 class="nav-link"
133                 to={{
134                   pathname: '/create_post',
135                   state: { prevPath: this.currentLocation },
136                 }}
137                 title={i18n.t('create_post')}
138               >
139                 {i18n.t('create_post')}
140               </Link>
141             </li>
142             <li class="nav-item">
143               <Link
144                 class="nav-link"
145                 to="/create_community"
146                 title={i18n.t('create_community')}
147               >
148                 {i18n.t('create_community')}
149               </Link>
150             </li>
151             <li className="nav-item">
152               <Link
153                 class="nav-link"
154                 to="/sponsors"
155                 title={i18n.t('donate_to_lemmy')}
156               >
157                 <svg class="icon">
158                   <use xlinkHref="#icon-coffee"></use>
159                 </svg>
160               </Link>
161             </li>
162           </ul>
163           <ul class="navbar-nav ml-auto">
164             {this.state.isLoggedIn ? (
165               <>
166                 <li className="nav-item mt-1">
167                   <Link class="nav-link" to="/inbox" title={i18n.t('inbox')}>
168                     <svg class="icon">
169                       <use xlinkHref="#icon-bell"></use>
170                     </svg>
171                     {this.state.unreadCount > 0 && (
172                       <span class="ml-1 badge badge-light">
173                         {this.state.unreadCount}
174                       </span>
175                     )}
176                   </Link>
177                 </li>
178                 <li className="nav-item">
179                   <Link
180                     class="nav-link"
181                     to={`/u/${UserService.Instance.user.username}`}
182                     title={i18n.t('settings')}
183                   >
184                     <span>
185                       {UserService.Instance.user.avatar && showAvatars() && (
186                         <img
187                           src={pictshareAvatarThumbnail(
188                             UserService.Instance.user.avatar
189                           )}
190                           height="32"
191                           width="32"
192                           class="rounded-circle mr-2"
193                         />
194                       )}
195                       {UserService.Instance.user.username}
196                     </span>
197                   </Link>
198                 </li>
199               </>
200             ) : (
201               <Link
202                 class="nav-link"
203                 to="/login"
204                 title={i18n.t('login_sign_up')}
205               >
206                 {i18n.t('login_sign_up')}
207               </Link>
208             )}
209           </ul>
210         </div>
211       </nav>
212     );
213   }
214
215   expandNavbar(i: Navbar) {
216     i.state.expanded = !i.state.expanded;
217     i.setState(i.state);
218   }
219
220   parseMessage(msg: WebSocketJsonResponse) {
221     let res = wsJsonToRes(msg);
222     if (msg.error) {
223       if (msg.error == 'not_logged_in') {
224         UserService.Instance.logout();
225         location.reload();
226       }
227       return;
228     } else if (msg.reconnect) {
229       this.fetchUnreads();
230     } else if (res.op == UserOperation.GetReplies) {
231       let data = res.data as GetRepliesResponse;
232       let unreadReplies = data.replies.filter(r => !r.read);
233
234       this.state.replies = unreadReplies;
235       this.state.unreadCount = this.calculateUnreadCount();
236       this.setState(this.state);
237       this.sendUnreadCount();
238     } else if (res.op == UserOperation.GetUserMentions) {
239       let data = res.data as GetUserMentionsResponse;
240       let unreadMentions = data.mentions.filter(r => !r.read);
241
242       this.state.mentions = unreadMentions;
243       this.state.unreadCount = this.calculateUnreadCount();
244       this.setState(this.state);
245       this.sendUnreadCount();
246     } else if (res.op == UserOperation.GetPrivateMessages) {
247       let data = res.data as PrivateMessagesResponse;
248       let unreadMessages = data.messages.filter(r => !r.read);
249
250       this.state.messages = unreadMessages;
251       this.state.unreadCount = this.calculateUnreadCount();
252       this.setState(this.state);
253       this.sendUnreadCount();
254     } else if (res.op == UserOperation.CreateComment) {
255       let data = res.data as CommentResponse;
256
257       if (this.state.isLoggedIn) {
258         if (data.recipient_ids.includes(UserService.Instance.user.id)) {
259           this.state.replies.push(data.comment);
260           this.state.unreadCount++;
261           this.setState(this.state);
262           this.sendUnreadCount();
263           this.notify(data.comment);
264         }
265       }
266     } else if (res.op == UserOperation.CreatePrivateMessage) {
267       let data = res.data as PrivateMessageResponse;
268
269       if (this.state.isLoggedIn) {
270         if (data.message.recipient_id == UserService.Instance.user.id) {
271           this.state.messages.push(data.message);
272           this.state.unreadCount++;
273           this.setState(this.state);
274           this.sendUnreadCount();
275           this.notify(data.message);
276         }
277       }
278     } else if (res.op == UserOperation.GetSite) {
279       let data = res.data as GetSiteResponse;
280
281       if (data.site && !this.state.siteName) {
282         this.state.siteName = data.site.name;
283         WebSocketService.Instance.site = data.site;
284         this.setState(this.state);
285       }
286     }
287   }
288
289   fetchUnreads() {
290     if (this.state.isLoggedIn) {
291       let repliesForm: GetRepliesForm = {
292         sort: SortType[SortType.New],
293         unread_only: true,
294         page: 1,
295         limit: fetchLimit,
296       };
297
298       let userMentionsForm: GetUserMentionsForm = {
299         sort: SortType[SortType.New],
300         unread_only: true,
301         page: 1,
302         limit: fetchLimit,
303       };
304
305       let privateMessagesForm: GetPrivateMessagesForm = {
306         unread_only: true,
307         page: 1,
308         limit: fetchLimit,
309       };
310
311       if (this.currentLocation !== '/inbox') {
312         WebSocketService.Instance.getReplies(repliesForm);
313         WebSocketService.Instance.getUserMentions(userMentionsForm);
314         WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
315       }
316     }
317   }
318
319   get currentLocation() {
320     return this.context.router.history.location.pathname;
321   }
322
323   sendUnreadCount() {
324     UserService.Instance.user.unreadCount = this.state.unreadCount;
325     UserService.Instance.sub.next({
326       user: UserService.Instance.user,
327     });
328   }
329
330   calculateUnreadCount(): number {
331     return (
332       this.state.replies.filter(r => !r.read).length +
333       this.state.mentions.filter(r => !r.read).length +
334       this.state.messages.filter(r => !r.read).length
335     );
336   }
337
338   requestNotificationPermission() {
339     if (UserService.Instance.user) {
340       document.addEventListener('DOMContentLoaded', function() {
341         if (!Notification) {
342           toast(i18n.t('notifications_error'), 'danger');
343           return;
344         }
345
346         if (Notification.permission !== 'granted')
347           Notification.requestPermission();
348       });
349     }
350   }
351
352   notify(reply: Comment | PrivateMessage) {
353     if (Notification.permission !== 'granted') Notification.requestPermission();
354     else {
355       var notification = new Notification(reply.creator_name, {
356         icon: reply.creator_avatar
357           ? reply.creator_avatar
358           : `${window.location.protocol}//${window.location.host}/static/assets/apple-touch-icon.png`,
359         body: `${reply.content}`,
360       });
361
362       notification.onclick = () => {
363         this.context.router.history.push(
364           isCommentType(reply)
365             ? `/post/${reply.post_id}/comment/${reply.id}`
366             : `/inbox`
367         );
368       };
369     }
370   }
371 }