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