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