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