]> Untitled Git - lemmy-ui.git/blob - src/shared/components/app/navbar.tsx
Remove websocket connection messages. Fixes #355
[lemmy-ui.git] / src / shared / components / app / navbar.tsx
1 import { Component, createRef, linkEvent, RefObject } from "inferno";
2 import { Link } from "inferno-router";
3 import {
4   CommentResponse,
5   CommentView,
6   GetPersonMentions,
7   GetPersonMentionsResponse,
8   GetPrivateMessages,
9   GetReplies,
10   GetRepliesResponse,
11   GetSiteResponse,
12   PrivateMessageResponse,
13   PrivateMessagesResponse,
14   PrivateMessageView,
15   SortType,
16   UserOperation,
17 } from "lemmy-js-client";
18 import { Subscription } from "rxjs";
19 import { i18n } from "../../i18next";
20 import { UserService, WebSocketService } from "../../services";
21 import {
22   authField,
23   fetchLimit,
24   getLanguage,
25   isBrowser,
26   notifyComment,
27   notifyPrivateMessage,
28   setTheme,
29   showAvatars,
30   supportLemmyUrl,
31   toast,
32   wsClient,
33   wsJsonToRes,
34   wsSubscribe,
35   wsUserOp,
36 } from "../../utils";
37 import { Icon } from "../common/icon";
38 import { PictrsImage } from "../common/pictrs-image";
39
40 interface NavbarProps {
41   site_res: GetSiteResponse;
42 }
43
44 interface NavbarState {
45   isLoggedIn: boolean;
46   expanded: boolean;
47   replies: CommentView[];
48   mentions: CommentView[];
49   messages: PrivateMessageView[];
50   unreadCount: number;
51   searchParam: string;
52   toggleSearch: boolean;
53   onSiteBanner?(url: string): any;
54 }
55
56 export class Navbar extends Component<NavbarProps, NavbarState> {
57   private wsSub: Subscription;
58   private userSub: Subscription;
59   private unreadCountSub: Subscription;
60   private searchTextField: RefObject<HTMLInputElement>;
61   emptyState: NavbarState = {
62     isLoggedIn: !!this.props.site_res.my_user,
63     unreadCount: 0,
64     replies: [],
65     mentions: [],
66     messages: [],
67     expanded: false,
68     searchParam: "",
69     toggleSearch: false,
70   };
71   subscription: any;
72
73   constructor(props: any, context: any) {
74     super(props, context);
75     this.state = this.emptyState;
76
77     this.parseMessage = this.parseMessage.bind(this);
78     this.subscription = wsSubscribe(this.parseMessage);
79   }
80
81   componentDidMount() {
82     // Subscribe to jwt changes
83     if (isBrowser()) {
84       this.websocketEvents();
85
86       this.searchTextField = createRef();
87       console.log(`isLoggedIn = ${this.state.isLoggedIn}`);
88
89       // On the first load, check the unreads
90       if (this.state.isLoggedIn == false) {
91         // setTheme(data.my_user.theme, true);
92         // i18n.changeLanguage(getLanguage());
93         // i18n.changeLanguage('de');
94       } else {
95         this.requestNotificationPermission();
96         WebSocketService.Instance.send(
97           wsClient.userJoin({
98             auth: authField(),
99           })
100         );
101         this.fetchUnreads();
102       }
103
104       this.userSub = UserService.Instance.jwtSub.subscribe(res => {
105         // A login
106         if (res !== undefined) {
107           this.requestNotificationPermission();
108           WebSocketService.Instance.send(
109             wsClient.getSite({ auth: authField() })
110           );
111         } else {
112           this.setState({ isLoggedIn: false });
113         }
114       });
115
116       // Subscribe to unread count changes
117       this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(
118         res => {
119           this.setState({ unreadCount: res });
120         }
121       );
122     }
123   }
124
125   handleSearchParam(i: Navbar, event: any) {
126     i.state.searchParam = event.target.value;
127     i.setState(i.state);
128   }
129
130   updateUrl() {
131     const searchParam = this.state.searchParam;
132     this.setState({ searchParam: "" });
133     this.setState({ toggleSearch: false });
134     if (searchParam === "") {
135       this.context.router.history.push(`/search/`);
136     } else {
137       const searchParamEncoded = encodeURIComponent(searchParam);
138       this.context.router.history.push(
139         `/search/q/${searchParamEncoded}/type/All/sort/TopAll/listing_type/All/community_id/0/creator_id/0/page/1`
140       );
141     }
142   }
143
144   handleSearchSubmit(i: Navbar, event: any) {
145     event.preventDefault();
146     i.updateUrl();
147   }
148
149   handleSearchBtn(i: Navbar, event: any) {
150     event.preventDefault();
151     i.setState({ toggleSearch: true });
152
153     i.searchTextField.current.focus();
154     const offsetWidth = i.searchTextField.current.offsetWidth;
155     if (i.state.searchParam && offsetWidth > 100) {
156       i.updateUrl();
157     }
158   }
159
160   handleSearchBlur(i: Navbar, event: any) {
161     if (!(event.relatedTarget && event.relatedTarget.name !== "search-btn")) {
162       i.state.toggleSearch = false;
163       i.setState(i.state);
164     }
165   }
166
167   render() {
168     return this.navbar();
169   }
170
171   componentWillUnmount() {
172     this.wsSub.unsubscribe();
173     this.userSub.unsubscribe();
174     this.unreadCountSub.unsubscribe();
175   }
176
177   // TODO class active corresponding to current page
178   navbar() {
179     let localUserView =
180       UserService.Instance.localUserView || this.props.site_res.my_user;
181     return (
182       <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
183         <div class="container">
184           {this.props.site_res.site_view && (
185             <Link
186               title={
187                 this.props.site_res.site_view.site.description ||
188                 this.props.site_res.site_view.site.name
189               }
190               className="d-flex align-items-center navbar-brand mr-md-3"
191               to="/"
192             >
193               {this.props.site_res.site_view.site.icon && showAvatars() && (
194                 <PictrsImage
195                   src={this.props.site_res.site_view.site.icon}
196                   icon
197                 />
198               )}
199               {this.props.site_res.site_view.site.name}
200             </Link>
201           )}
202           {this.state.isLoggedIn && (
203             <Link
204               className="ml-auto p-1 navbar-toggler nav-link border-0"
205               to="/inbox"
206               title={i18n.t("inbox")}
207             >
208               <Icon icon="bell" />
209               {this.state.unreadCount > 0 && (
210                 <span
211                   class="mx-1 badge badge-light"
212                   aria-label={`${this.state.unreadCount} ${i18n.t(
213                     "unread_messages"
214                   )}`}
215                 >
216                   {this.state.unreadCount}
217                 </span>
218               )}
219             </Link>
220           )}
221           <button
222             class="navbar-toggler border-0 p-1"
223             type="button"
224             aria-label="menu"
225             onClick={linkEvent(this, this.expandNavbar)}
226             data-tippy-content={i18n.t("expand_here")}
227           >
228             <Icon icon="menu" />
229           </button>
230           <div
231             className={`${!this.state.expanded && "collapse"} navbar-collapse`}
232           >
233             <ul class="navbar-nav my-2 mr-auto">
234               <li class="nav-item">
235                 <Link
236                   className="nav-link"
237                   to="/communities"
238                   title={i18n.t("communities")}
239                 >
240                   {i18n.t("communities")}
241                 </Link>
242               </li>
243               <li class="nav-item">
244                 <Link
245                   className="nav-link"
246                   to={{
247                     pathname: "/create_post",
248                     state: { prevPath: this.currentLocation },
249                   }}
250                   title={i18n.t("create_post")}
251                 >
252                   {i18n.t("create_post")}
253                 </Link>
254               </li>
255               {this.canCreateCommunity && (
256                 <li class="nav-item">
257                   <Link
258                     className="nav-link"
259                     to="/create_community"
260                     title={i18n.t("create_community")}
261                   >
262                     {i18n.t("create_community")}
263                   </Link>
264                 </li>
265               )}
266               <li class="nav-item">
267                 <a
268                   className="nav-link"
269                   title={i18n.t("support_lemmy")}
270                   href={supportLemmyUrl}
271                 >
272                   <Icon icon="heart" classes="small" />
273                 </a>
274               </li>
275             </ul>
276             <ul class="navbar-nav my-2">
277               {this.canAdmin && (
278                 <li className="nav-item">
279                   <Link
280                     className="nav-link"
281                     to={`/admin`}
282                     title={i18n.t("admin_settings")}
283                   >
284                     <Icon icon="settings" />
285                   </Link>
286                 </li>
287               )}
288             </ul>
289             {!this.context.router.history.location.pathname.match(
290               /^\/search/
291             ) && (
292               <form
293                 class="form-inline"
294                 onSubmit={linkEvent(this, this.handleSearchSubmit)}
295               >
296                 <input
297                   id="search-input"
298                   class={`form-control mr-0 search-input ${
299                     this.state.toggleSearch ? "show-input" : "hide-input"
300                   }`}
301                   onInput={linkEvent(this, this.handleSearchParam)}
302                   value={this.state.searchParam}
303                   ref={this.searchTextField}
304                   type="text"
305                   placeholder={i18n.t("search")}
306                   onBlur={linkEvent(this, this.handleSearchBlur)}
307                 ></input>
308                 <label class="sr-only" htmlFor="search-input">
309                   {i18n.t("search")}
310                 </label>
311                 <button
312                   name="search-btn"
313                   onClick={linkEvent(this, this.handleSearchBtn)}
314                   class="px-1 btn btn-link"
315                   style="color: var(--gray)"
316                   aria-label={i18n.t("search")}
317                 >
318                   <Icon icon="search" />
319                 </button>
320               </form>
321             )}
322             {this.state.isLoggedIn ? (
323               <>
324                 <ul class="navbar-nav my-2">
325                   <li className="nav-item">
326                     <Link
327                       className="nav-link"
328                       to="/inbox"
329                       title={i18n.t("inbox")}
330                     >
331                       <Icon icon="bell" />
332                       {this.state.unreadCount > 0 && (
333                         <span
334                           class="ml-1 badge badge-light"
335                           aria-label={`${this.state.unreadCount} ${i18n.t(
336                             "unread_messages"
337                           )}`}
338                         >
339                           {this.state.unreadCount}
340                         </span>
341                       )}
342                     </Link>
343                   </li>
344                 </ul>
345                 <ul class="navbar-nav">
346                   <li className="nav-item">
347                     <Link
348                       className="nav-link"
349                       to={`/u/${localUserView.person.name}`}
350                       title={i18n.t("settings")}
351                     >
352                       <span>
353                         {localUserView.person.avatar && showAvatars() && (
354                           <PictrsImage src={localUserView.person.avatar} icon />
355                         )}
356                         {localUserView.person.display_name
357                           ? localUserView.person.display_name
358                           : localUserView.person.name}
359                       </span>
360                     </Link>
361                   </li>
362                 </ul>
363               </>
364             ) : (
365               <ul class="navbar-nav my-2">
366                 <li className="ml-2 nav-item">
367                   <Link
368                     className="btn btn-success"
369                     to="/login"
370                     title={i18n.t("login_sign_up")}
371                   >
372                     {i18n.t("login_sign_up")}
373                   </Link>
374                 </li>
375               </ul>
376             )}
377           </div>
378         </div>
379       </nav>
380     );
381   }
382
383   expandNavbar(i: Navbar) {
384     i.state.expanded = !i.state.expanded;
385     i.setState(i.state);
386   }
387
388   parseMessage(msg: any) {
389     let op = wsUserOp(msg);
390     console.log(msg);
391     if (msg.error) {
392       if (msg.error == "not_logged_in") {
393         UserService.Instance.logout();
394         location.reload();
395       }
396       return;
397     } else if (msg.reconnect) {
398       console.log(i18n.t("websocket_reconnected"));
399       WebSocketService.Instance.send(
400         wsClient.userJoin({
401           auth: authField(),
402         })
403       );
404       this.fetchUnreads();
405     } else if (op == UserOperation.GetReplies) {
406       let data = wsJsonToRes<GetRepliesResponse>(msg).data;
407       let unreadReplies = data.replies.filter(r => !r.comment.read);
408
409       this.state.replies = unreadReplies;
410       this.state.unreadCount = this.calculateUnreadCount();
411       this.setState(this.state);
412       this.sendUnreadCount();
413     } else if (op == UserOperation.GetPersonMentions) {
414       let data = wsJsonToRes<GetPersonMentionsResponse>(msg).data;
415       let unreadMentions = data.mentions.filter(r => !r.comment.read);
416
417       this.state.mentions = unreadMentions;
418       this.state.unreadCount = this.calculateUnreadCount();
419       this.setState(this.state);
420       this.sendUnreadCount();
421     } else if (op == UserOperation.GetPrivateMessages) {
422       let data = wsJsonToRes<PrivateMessagesResponse>(msg).data;
423       let unreadMessages = data.private_messages.filter(
424         r => !r.private_message.read
425       );
426
427       this.state.messages = unreadMessages;
428       this.state.unreadCount = this.calculateUnreadCount();
429       this.setState(this.state);
430       this.sendUnreadCount();
431     } else if (op == UserOperation.GetSite) {
432       // This is only called on a successful login
433       let data = wsJsonToRes<GetSiteResponse>(msg).data;
434       console.log(data.my_user);
435       UserService.Instance.localUserView = data.my_user;
436       setTheme(UserService.Instance.localUserView.local_user.theme);
437       i18n.changeLanguage(getLanguage());
438       this.state.isLoggedIn = true;
439       this.setState(this.state);
440     } else if (op == UserOperation.CreateComment) {
441       let data = wsJsonToRes<CommentResponse>(msg).data;
442
443       if (this.state.isLoggedIn) {
444         if (
445           data.recipient_ids.includes(
446             UserService.Instance.localUserView.local_user.id
447           )
448         ) {
449           this.state.replies.push(data.comment_view);
450           this.state.unreadCount++;
451           this.setState(this.state);
452           this.sendUnreadCount();
453           notifyComment(data.comment_view, this.context.router);
454         }
455       }
456     } else if (op == UserOperation.CreatePrivateMessage) {
457       let data = wsJsonToRes<PrivateMessageResponse>(msg).data;
458
459       if (this.state.isLoggedIn) {
460         if (
461           data.private_message_view.recipient.id ==
462           UserService.Instance.localUserView.person.id
463         ) {
464           this.state.messages.push(data.private_message_view);
465           this.state.unreadCount++;
466           this.setState(this.state);
467           this.sendUnreadCount();
468           notifyPrivateMessage(data.private_message_view, this.context.router);
469         }
470       }
471     }
472   }
473
474   fetchUnreads() {
475     console.log("Fetching unreads...");
476     let repliesForm: GetReplies = {
477       sort: SortType.New,
478       unread_only: true,
479       page: 1,
480       limit: fetchLimit,
481       auth: authField(),
482     };
483
484     let personMentionsForm: GetPersonMentions = {
485       sort: SortType.New,
486       unread_only: true,
487       page: 1,
488       limit: fetchLimit,
489       auth: authField(),
490     };
491
492     let privateMessagesForm: GetPrivateMessages = {
493       unread_only: true,
494       page: 1,
495       limit: fetchLimit,
496       auth: authField(),
497     };
498
499     if (this.currentLocation !== "/inbox") {
500       WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
501       WebSocketService.Instance.send(
502         wsClient.getPersonMentions(personMentionsForm)
503       );
504       WebSocketService.Instance.send(
505         wsClient.getPrivateMessages(privateMessagesForm)
506       );
507     }
508   }
509
510   get currentLocation() {
511     return this.context.router.history.location.pathname;
512   }
513
514   sendUnreadCount() {
515     UserService.Instance.unreadCountSub.next(this.state.unreadCount);
516   }
517
518   calculateUnreadCount(): number {
519     return (
520       this.state.replies.filter(r => !r.comment.read).length +
521       this.state.mentions.filter(r => !r.comment.read).length +
522       this.state.messages.filter(r => !r.private_message.read).length
523     );
524   }
525
526   get canAdmin(): boolean {
527     return (
528       UserService.Instance.localUserView &&
529       this.props.site_res.admins
530         .map(a => a.person.id)
531         .includes(UserService.Instance.localUserView.person.id)
532     );
533   }
534
535   get canCreateCommunity(): boolean {
536     let adminOnly =
537       this.props.site_res.site_view?.site.community_creation_admin_only;
538     return !adminOnly || this.canAdmin;
539   }
540
541   /// Listens for some websocket errors
542   websocketEvents() {
543     let msg = i18n.t("websocket_disconnected");
544     WebSocketService.Instance.closeEventListener(() => {
545       console.error(msg);
546     });
547   }
548
549   requestNotificationPermission() {
550     if (UserService.Instance.localUserView) {
551       document.addEventListener("DOMContentLoaded", function () {
552         if (!Notification) {
553           toast(i18n.t("notifications_error"), "danger");
554           return;
555         }
556
557         if (Notification.permission !== "granted")
558           Notification.requestPermission();
559       });
560     }
561   }
562 }