]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
Comment Tree paging (#726)
[lemmy-ui.git] / src / shared / utils.ts
1 import { None, Option, Result, Some } from "@sniptt/monads";
2 import { ClassConstructor, deserialize, serialize } from "class-transformer";
3 import emojiShortName from "emoji-short-name";
4 import {
5   BlockCommunityResponse,
6   BlockPersonResponse,
7   Comment as CommentI,
8   CommentNode as CommentNodeI,
9   CommentReportView,
10   CommentSortType,
11   CommentView,
12   CommunityBlockView,
13   CommunityModeratorView,
14   CommunityView,
15   GetSiteMetadata,
16   GetSiteResponse,
17   LemmyHttp,
18   LemmyWebsocket,
19   ListingType,
20   MyUserInfo,
21   PersonBlockView,
22   PersonSafe,
23   PersonViewSafe,
24   PostReportView,
25   PostView,
26   PrivateMessageView,
27   RegistrationApplicationView,
28   Search,
29   SearchType,
30   SortType,
31 } from "lemmy-js-client";
32 import markdown_it from "markdown-it";
33 import markdown_it_container from "markdown-it-container";
34 import markdown_it_footnote from "markdown-it-footnote";
35 import markdown_it_html5_embed from "markdown-it-html5-embed";
36 import markdown_it_sub from "markdown-it-sub";
37 import markdown_it_sup from "markdown-it-sup";
38 import moment from "moment";
39 import { Subscription } from "rxjs";
40 import { delay, retryWhen, take } from "rxjs/operators";
41 import tippy from "tippy.js";
42 import Toastify from "toastify-js";
43 import { httpBase } from "./env";
44 import { i18n, languages } from "./i18next";
45 import { DataType, IsoData } from "./interfaces";
46 import { UserService, WebSocketService } from "./services";
47
48 var Tribute: any;
49 if (isBrowser()) {
50   Tribute = require("tributejs");
51 }
52
53 export const wsClient = new LemmyWebsocket();
54
55 export const favIconUrl = "/static/assets/icons/favicon.svg";
56 export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
57 // TODO
58 // export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
59 export const repoUrl = "https://github.com/LemmyNet";
60 export const joinLemmyUrl = "https://join-lemmy.org";
61 export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
62 export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`;
63 export const helpGuideUrl = `${joinLemmyUrl}/docs/en/about/guide.html`; // TODO find a way to redirect to the non-en folder
64 export const markdownHelpUrl = `${helpGuideUrl}#using-markdown`;
65 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
66 export const archiveTodayUrl = "https://archive.today";
67 export const ghostArchiveUrl = "https://ghostarchive.org";
68 export const webArchiveUrl = "https://web.archive.org";
69 export const elementUrl = "https://element.io";
70
71 export const postRefetchSeconds: number = 60 * 1000;
72 export const fetchLimit = 20;
73 export const trendingFetchLimit = 6;
74 export const mentionDropdownFetchLimit = 10;
75 export const commentTreeMaxDepth = 8;
76
77 export const relTags = "noopener nofollow";
78
79 const DEFAULT_ALPHABET =
80   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
81
82 function getRandomCharFromAlphabet(alphabet: string): string {
83   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
84 }
85
86 export function randomStr(
87   idDesiredLength = 20,
88   alphabet = DEFAULT_ALPHABET
89 ): string {
90   /**
91    * Create n-long array and map it to random chars from given alphabet.
92    * Then join individual chars as string
93    */
94   return Array.from({ length: idDesiredLength })
95     .map(() => {
96       return getRandomCharFromAlphabet(alphabet);
97     })
98     .join("");
99 }
100
101 export const md = new markdown_it({
102   html: false,
103   linkify: true,
104   typographer: true,
105 })
106   .use(markdown_it_sub)
107   .use(markdown_it_sup)
108   .use(markdown_it_footnote)
109   .use(markdown_it_html5_embed, {
110     html5embed: {
111       useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
112       attributes: {
113         audio: 'controls preload="metadata"',
114         video:
115           'width="100%" max-height="100%" controls loop preload="metadata"',
116       },
117     },
118   })
119   .use(markdown_it_container, "spoiler", {
120     validate: function (params: any) {
121       return params.trim().match(/^spoiler\s+(.*)$/);
122     },
123
124     render: function (tokens: any, idx: any) {
125       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
126
127       if (tokens[idx].nesting === 1) {
128         // opening tag
129         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
130       } else {
131         // closing tag
132         return "</details>\n";
133       }
134     },
135   });
136
137 export function hotRankComment(comment_view: CommentView): number {
138   return hotRank(comment_view.counts.score, comment_view.comment.published);
139 }
140
141 export function hotRankActivePost(post_view: PostView): number {
142   return hotRank(post_view.counts.score, post_view.counts.newest_comment_time);
143 }
144
145 export function hotRankPost(post_view: PostView): number {
146   return hotRank(post_view.counts.score, post_view.post.published);
147 }
148
149 export function hotRank(score: number, timeStr: string): number {
150   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
151   let date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
152   let now: Date = new Date();
153   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
154
155   let rank =
156     (10000 * Math.log10(Math.max(1, 3 + score))) /
157     Math.pow(hoursElapsed + 2, 1.8);
158
159   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
160
161   return rank;
162 }
163
164 export function mdToHtml(text: string) {
165   return { __html: md.render(text) };
166 }
167
168 export function getUnixTime(text: string): number {
169   return text ? new Date(text).getTime() / 1000 : undefined;
170 }
171
172 export function futureDaysToUnixTime(days: number): number {
173   return days
174     ? Math.trunc(
175         new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000
176       )
177     : undefined;
178 }
179
180 export function canMod(
181   mods: Option<CommunityModeratorView[]>,
182   admins: Option<PersonViewSafe[]>,
183   creator_id: number,
184   myUserInfo = UserService.Instance.myUserInfo,
185   onSelf = false
186 ): boolean {
187   // You can do moderator actions only on the mods added after you.
188   let adminsThenMods = admins
189     .unwrapOr([])
190     .map(a => a.person.id)
191     .concat(mods.unwrapOr([]).map(m => m.moderator.id));
192
193   return myUserInfo.match({
194     some: me => {
195       let myIndex = adminsThenMods.findIndex(
196         id => id == me.local_user_view.person.id
197       );
198       if (myIndex == -1) {
199         return false;
200       } else {
201         // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
202         adminsThenMods = adminsThenMods.slice(0, myIndex + (onSelf ? 0 : 1));
203         return !adminsThenMods.includes(creator_id);
204       }
205     },
206     none: false,
207   });
208 }
209
210 export function canAdmin(
211   admins: Option<PersonViewSafe[]>,
212   creator_id: number,
213   myUserInfo = UserService.Instance.myUserInfo
214 ): boolean {
215   return canMod(None, admins, creator_id, myUserInfo);
216 }
217
218 export function isMod(
219   mods: Option<CommunityModeratorView[]>,
220   creator_id: number
221 ): boolean {
222   return mods.match({
223     some: mods => mods.map(m => m.moderator.id).includes(creator_id),
224     none: false,
225   });
226 }
227
228 export function amMod(
229   mods: Option<CommunityModeratorView[]>,
230   myUserInfo = UserService.Instance.myUserInfo
231 ): boolean {
232   return myUserInfo.match({
233     some: mui => isMod(mods, mui.local_user_view.person.id),
234     none: false,
235   });
236 }
237
238 export function isAdmin(
239   admins: Option<PersonViewSafe[]>,
240   creator_id: number
241 ): boolean {
242   return admins.match({
243     some: admins => admins.map(a => a.person.id).includes(creator_id),
244     none: false,
245   });
246 }
247
248 export function amAdmin(
249   admins: Option<PersonViewSafe[]>,
250   myUserInfo = UserService.Instance.myUserInfo
251 ): boolean {
252   return myUserInfo.match({
253     some: mui => isAdmin(admins, mui.local_user_view.person.id),
254     none: false,
255   });
256 }
257
258 export function amCommunityCreator(
259   mods: Option<CommunityModeratorView[]>,
260   creator_id: number,
261   myUserInfo = UserService.Instance.myUserInfo
262 ): boolean {
263   return mods.match({
264     some: mods =>
265       myUserInfo
266         .map(mui => mui.local_user_view.person.id)
267         .match({
268           some: myId =>
269             myId == mods[0].moderator.id &&
270             // Don't allow mod actions on yourself
271             myId != creator_id,
272           none: false,
273         }),
274     none: false,
275   });
276 }
277
278 export function amSiteCreator(
279   admins: Option<PersonViewSafe[]>,
280   creator_id: number,
281   myUserInfo = UserService.Instance.myUserInfo
282 ): boolean {
283   return admins.match({
284     some: admins =>
285       myUserInfo
286         .map(mui => mui.local_user_view.person.id)
287         .match({
288           some: myId =>
289             myId == admins[0].person.id &&
290             // Don't allow mod actions on yourself
291             myId != creator_id,
292           none: false,
293         }),
294     none: false,
295   });
296 }
297
298 export function amTopMod(
299   mods: Option<CommunityModeratorView[]>,
300   myUserInfo = UserService.Instance.myUserInfo
301 ): boolean {
302   return mods.match({
303     some: mods =>
304       myUserInfo.match({
305         some: mui => mods[0].moderator.id == mui.local_user_view.person.id,
306         none: false,
307       }),
308     none: false,
309   });
310 }
311
312 const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
313 const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/;
314
315 export function isImage(url: string) {
316   return imageRegex.test(url);
317 }
318
319 export function isVideo(url: string) {
320   return videoRegex.test(url);
321 }
322
323 export function validURL(str: string) {
324   return !!new URL(str);
325 }
326
327 export function communityRSSUrl(actorId: string, sort: string): string {
328   let url = new URL(actorId);
329   return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
330 }
331
332 export function validEmail(email: string) {
333   let re =
334     /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
335   return re.test(String(email).toLowerCase());
336 }
337
338 export function capitalizeFirstLetter(str: string): string {
339   return str.charAt(0).toUpperCase() + str.slice(1);
340 }
341
342 export function routeSortTypeToEnum(sort: string): SortType {
343   return SortType[sort];
344 }
345
346 export function listingTypeFromNum(type_: number): ListingType {
347   return Object.values(ListingType)[type_];
348 }
349
350 export function sortTypeFromNum(type_: number): SortType {
351   return Object.values(SortType)[type_];
352 }
353
354 export function routeListingTypeToEnum(type: string): ListingType {
355   return ListingType[type];
356 }
357
358 export function routeDataTypeToEnum(type: string): DataType {
359   return DataType[capitalizeFirstLetter(type)];
360 }
361
362 export function routeSearchTypeToEnum(type: string): SearchType {
363   return SearchType[type];
364 }
365
366 export async function getSiteMetadata(url: string) {
367   let form = new GetSiteMetadata({
368     url,
369   });
370   let client = new LemmyHttp(httpBase);
371   return client.getSiteMetadata(form);
372 }
373
374 export function debounce(func: any, wait = 1000, immediate = false) {
375   // 'private' variable for instance
376   // The returned function will be able to reference this due to closure.
377   // Each call to the returned function will share this common timer.
378   let timeout: any;
379
380   // Calling debounce returns a new anonymous function
381   return function () {
382     // reference the context and args for the setTimeout function
383     var args = arguments;
384
385     // Should the function be called now? If immediate is true
386     //   and not already in a timeout then the answer is: Yes
387     var callNow = immediate && !timeout;
388
389     // This is the basic debounce behaviour where you can call this
390     //   function several times, but it will only execute once
391     //   [before or after imposing a delay].
392     //   Each time the returned function is called, the timer starts over.
393     clearTimeout(timeout);
394
395     // Set the new timeout
396     timeout = setTimeout(function () {
397       // Inside the timeout function, clear the timeout variable
398       // which will let the next execution run when in 'immediate' mode
399       timeout = null;
400
401       // Check if the function already ran with the immediate flag
402       if (!immediate) {
403         // Call the original function with apply
404         // apply lets you define the 'this' object as well as the arguments
405         //    (both captured before setTimeout)
406         func.apply(this, args);
407       }
408     }, wait);
409
410     // Immediate mode and no wait timer? Execute the function..
411     if (callNow) func.apply(this, args);
412   };
413 }
414
415 export function getLanguages(
416   override?: string,
417   myUserInfo = UserService.Instance.myUserInfo
418 ): string[] {
419   let myLang = myUserInfo
420     .map(m => m.local_user_view.local_user.lang)
421     .unwrapOr("browser");
422   let lang = override || myLang;
423
424   if (lang == "browser" && isBrowser()) {
425     return getBrowserLanguages();
426   } else {
427     return [lang];
428   }
429 }
430
431 function getBrowserLanguages(): string[] {
432   // Intersect lemmy's langs, with the browser langs
433   let langs = languages ? languages.map(l => l.code) : ["en"];
434
435   // NOTE, mobile browsers seem to be missing this list, so append en
436   let allowedLangs = navigator.languages
437     .concat("en")
438     .filter(v => langs.includes(v));
439   return allowedLangs;
440 }
441
442 export async function fetchThemeList(): Promise<string[]> {
443   return fetch("/css/themelist").then(res => res.json());
444 }
445
446 export async function setTheme(theme: string, forceReload = false) {
447   if (!isBrowser()) {
448     return;
449   }
450   if (theme === "browser" && !forceReload) {
451     return;
452   }
453   // This is only run on a force reload
454   if (theme == "browser") {
455     theme = "darkly";
456   }
457
458   let themeList = await fetchThemeList();
459
460   // Unload all the other themes
461   for (var i = 0; i < themeList.length; i++) {
462     let styleSheet = document.getElementById(themeList[i]);
463     if (styleSheet) {
464       styleSheet.setAttribute("disabled", "disabled");
465     }
466   }
467
468   document
469     .getElementById("default-light")
470     ?.setAttribute("disabled", "disabled");
471   document.getElementById("default-dark")?.setAttribute("disabled", "disabled");
472
473   // Load the theme dynamically
474   let cssLoc = `/css/themes/${theme}.css`;
475
476   loadCss(theme, cssLoc);
477   document.getElementById(theme).removeAttribute("disabled");
478 }
479
480 export function loadCss(id: string, loc: string) {
481   if (!document.getElementById(id)) {
482     var head = document.getElementsByTagName("head")[0];
483     var link = document.createElement("link");
484     link.id = id;
485     link.rel = "stylesheet";
486     link.type = "text/css";
487     link.href = loc;
488     link.media = "all";
489     head.appendChild(link);
490   }
491 }
492
493 export function objectFlip(obj: any) {
494   const ret = {};
495   Object.keys(obj).forEach(key => {
496     ret[obj[key]] = key;
497   });
498   return ret;
499 }
500
501 export function showAvatars(
502   myUserInfo: Option<MyUserInfo> = UserService.Instance.myUserInfo
503 ): boolean {
504   return myUserInfo
505     .map(m => m.local_user_view.local_user.show_avatars)
506     .unwrapOr(true);
507 }
508
509 export function showScores(
510   myUserInfo: Option<MyUserInfo> = UserService.Instance.myUserInfo
511 ): boolean {
512   return myUserInfo
513     .map(m => m.local_user_view.local_user.show_scores)
514     .unwrapOr(true);
515 }
516
517 export function isCakeDay(published: string): boolean {
518   // moment(undefined) or moment.utc(undefined) returns the current date/time
519   // moment(null) or moment.utc(null) returns null
520   const createDate = moment.utc(published).local();
521   const currentDate = moment(new Date());
522
523   return (
524     createDate.date() === currentDate.date() &&
525     createDate.month() === currentDate.month() &&
526     createDate.year() !== currentDate.year()
527   );
528 }
529
530 export function toast(text: string, background = "success") {
531   if (isBrowser()) {
532     let backgroundColor = `var(--${background})`;
533     Toastify({
534       text: text,
535       backgroundColor: backgroundColor,
536       gravity: "bottom",
537       position: "left",
538     }).showToast();
539   }
540 }
541
542 export function pictrsDeleteToast(
543   clickToDeleteText: string,
544   deletePictureText: string,
545   deleteUrl: string
546 ) {
547   if (isBrowser()) {
548     let backgroundColor = `var(--light)`;
549     let toast = Toastify({
550       text: clickToDeleteText,
551       backgroundColor: backgroundColor,
552       gravity: "top",
553       position: "right",
554       duration: 10000,
555       onClick: () => {
556         if (toast) {
557           window.location.replace(deleteUrl);
558           alert(deletePictureText);
559           toast.hideToast();
560         }
561       },
562       close: true,
563     }).showToast();
564   }
565 }
566
567 interface NotifyInfo {
568   name: string;
569   icon: Option<string>;
570   link: string;
571   body: string;
572 }
573
574 export function messageToastify(info: NotifyInfo, router: any) {
575   if (isBrowser()) {
576     let htmlBody = info.body ? md.render(info.body) : "";
577     let backgroundColor = `var(--light)`;
578
579     let toast = Toastify({
580       text: `${htmlBody}<br />${info.name}`,
581       avatar: info.icon,
582       backgroundColor: backgroundColor,
583       className: "text-dark",
584       close: true,
585       gravity: "top",
586       position: "right",
587       duration: 5000,
588       escapeMarkup: false,
589       onClick: () => {
590         if (toast) {
591           toast.hideToast();
592           router.history.push(info.link);
593         }
594       },
595     }).showToast();
596   }
597 }
598
599 export function notifyPost(post_view: PostView, router: any) {
600   let info: NotifyInfo = {
601     name: post_view.community.name,
602     icon: post_view.community.icon,
603     link: `/post/${post_view.post.id}`,
604     body: post_view.post.name,
605   };
606   notify(info, router);
607 }
608
609 export function notifyComment(comment_view: CommentView, router: any) {
610   let info: NotifyInfo = {
611     name: comment_view.creator.name,
612     icon: comment_view.creator.avatar,
613     link: `/comment/${comment_view.comment.id}`,
614     body: comment_view.comment.content,
615   };
616   notify(info, router);
617 }
618
619 export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
620   let info: NotifyInfo = {
621     name: pmv.creator.name,
622     icon: pmv.creator.avatar,
623     link: `/inbox`,
624     body: pmv.private_message.content,
625   };
626   notify(info, router);
627 }
628
629 function notify(info: NotifyInfo, router: any) {
630   messageToastify(info, router);
631
632   // TODO absolute nightmare bug, but notifs are currently broken.
633   // Notification.new will try to do a browser fetch ???
634
635   // if (Notification.permission !== "granted") Notification.requestPermission();
636   // else {
637   //   var notification = new Notification(info.name, {
638   //     icon: info.icon,
639   //     body: info.body,
640   //   });
641
642   //   notification.onclick = (ev: Event): any => {
643   //     ev.preventDefault();
644   //     router.history.push(info.link);
645   //   };
646   // }
647 }
648
649 export function setupTribute() {
650   return new Tribute({
651     noMatchTemplate: function () {
652       return "";
653     },
654     collection: [
655       // Emojis
656       {
657         trigger: ":",
658         menuItemTemplate: (item: any) => {
659           let shortName = `:${item.original.key}:`;
660           return `${item.original.val} ${shortName}`;
661         },
662         selectTemplate: (item: any) => {
663           return `${item.original.val}`;
664         },
665         values: Object.entries(emojiShortName).map(e => {
666           return { key: e[1], val: e[0] };
667         }),
668         allowSpaces: false,
669         autocompleteMode: true,
670         // TODO
671         // menuItemLimit: mentionDropdownFetchLimit,
672         menuShowMinLength: 2,
673       },
674       // Persons
675       {
676         trigger: "@",
677         selectTemplate: (item: any) => {
678           let it: PersonTribute = item.original;
679           return `[${it.key}](${it.view.person.actor_id})`;
680         },
681         values: debounce(async (text: string, cb: any) => {
682           cb(await personSearch(text));
683         }),
684         allowSpaces: false,
685         autocompleteMode: true,
686         // TODO
687         // menuItemLimit: mentionDropdownFetchLimit,
688         menuShowMinLength: 2,
689       },
690
691       // Communities
692       {
693         trigger: "!",
694         selectTemplate: (item: any) => {
695           let it: CommunityTribute = item.original;
696           return `[${it.key}](${it.view.community.actor_id})`;
697         },
698         values: debounce(async (text: string, cb: any) => {
699           cb(await communitySearch(text));
700         }),
701         allowSpaces: false,
702         autocompleteMode: true,
703         // TODO
704         // menuItemLimit: mentionDropdownFetchLimit,
705         menuShowMinLength: 2,
706       },
707     ],
708   });
709 }
710
711 var tippyInstance: any;
712 if (isBrowser()) {
713   tippyInstance = tippy("[data-tippy-content]");
714 }
715
716 export function setupTippy() {
717   if (isBrowser()) {
718     tippyInstance.forEach((e: any) => e.destroy());
719     tippyInstance = tippy("[data-tippy-content]", {
720       delay: [500, 0],
721       // Display on "long press"
722       touch: ["hold", 500],
723     });
724   }
725 }
726
727 interface PersonTribute {
728   key: string;
729   view: PersonViewSafe;
730 }
731
732 async function personSearch(text: string): Promise<PersonTribute[]> {
733   let users = (await fetchUsers(text)).users;
734   let persons: PersonTribute[] = users.map(pv => {
735     let tribute: PersonTribute = {
736       key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
737       view: pv,
738     };
739     return tribute;
740   });
741   return persons;
742 }
743
744 interface CommunityTribute {
745   key: string;
746   view: CommunityView;
747 }
748
749 async function communitySearch(text: string): Promise<CommunityTribute[]> {
750   let comms = (await fetchCommunities(text)).communities;
751   let communities: CommunityTribute[] = comms.map(cv => {
752     let tribute: CommunityTribute = {
753       key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
754       view: cv,
755     };
756     return tribute;
757   });
758   return communities;
759 }
760
761 export function getListingTypeFromProps(
762   props: any,
763   defaultListingType: ListingType,
764   myUserInfo = UserService.Instance.myUserInfo
765 ): ListingType {
766   return props.match.params.listing_type
767     ? routeListingTypeToEnum(props.match.params.listing_type)
768     : myUserInfo.match({
769         some: me =>
770           Object.values(ListingType)[
771             me.local_user_view.local_user.default_listing_type
772           ],
773         none: defaultListingType,
774       });
775 }
776
777 export function getListingTypeFromPropsNoDefault(props: any): ListingType {
778   return props.match.params.listing_type
779     ? routeListingTypeToEnum(props.match.params.listing_type)
780     : ListingType.Local;
781 }
782
783 // TODO might need to add a user setting for this too
784 export function getDataTypeFromProps(props: any): DataType {
785   return props.match.params.data_type
786     ? routeDataTypeToEnum(props.match.params.data_type)
787     : DataType.Post;
788 }
789
790 export function getSortTypeFromProps(
791   props: any,
792   myUserInfo = UserService.Instance.myUserInfo
793 ): SortType {
794   return props.match.params.sort
795     ? routeSortTypeToEnum(props.match.params.sort)
796     : myUserInfo.match({
797         some: mui =>
798           Object.values(SortType)[
799             mui.local_user_view.local_user.default_sort_type
800           ],
801         none: SortType.Active,
802       });
803 }
804
805 export function getPageFromProps(props: any): number {
806   return props.match.params.page ? Number(props.match.params.page) : 1;
807 }
808
809 export function getRecipientIdFromProps(props: any): number {
810   return props.match.params.recipient_id
811     ? Number(props.match.params.recipient_id)
812     : 1;
813 }
814
815 export function getIdFromProps(props: any): Option<number> {
816   let id: string = props.match.params.post_id;
817   return id ? Some(Number(id)) : None;
818 }
819
820 export function getCommentIdFromProps(props: any): Option<number> {
821   let id: string = props.match.params.comment_id;
822   return id ? Some(Number(id)) : None;
823 }
824
825 export function getUsernameFromProps(props: any): string {
826   return props.match.params.username;
827 }
828
829 export function editCommentRes(data: CommentView, comments: CommentView[]) {
830   let found = comments.find(c => c.comment.id == data.comment.id);
831   if (found) {
832     found.comment.content = data.comment.content;
833     found.comment.updated = data.comment.updated;
834     found.comment.removed = data.comment.removed;
835     found.comment.deleted = data.comment.deleted;
836     found.counts.upvotes = data.counts.upvotes;
837     found.counts.downvotes = data.counts.downvotes;
838     found.counts.score = data.counts.score;
839   }
840 }
841
842 export function saveCommentRes(data: CommentView, comments: CommentView[]) {
843   let found = comments.find(c => c.comment.id == data.comment.id);
844   if (found) {
845     found.saved = data.saved;
846   }
847 }
848
849 // TODO Should only use the return now, no state?
850 export function updatePersonBlock(
851   data: BlockPersonResponse,
852   myUserInfo = UserService.Instance.myUserInfo
853 ): Option<PersonBlockView[]> {
854   return myUserInfo.match({
855     some: (mui: MyUserInfo) => {
856       if (data.blocked) {
857         mui.person_blocks.push({
858           person: mui.local_user_view.person,
859           target: data.person_view.person,
860         });
861         toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
862       } else {
863         mui.person_blocks = mui.person_blocks.filter(
864           i => i.target.id != data.person_view.person.id
865         );
866         toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
867       }
868       return Some(mui.person_blocks);
869     },
870     none: None,
871   });
872 }
873
874 export function updateCommunityBlock(
875   data: BlockCommunityResponse,
876   myUserInfo = UserService.Instance.myUserInfo
877 ): Option<CommunityBlockView[]> {
878   return myUserInfo.match({
879     some: (mui: MyUserInfo) => {
880       if (data.blocked) {
881         mui.community_blocks.push({
882           person: mui.local_user_view.person,
883           community: data.community_view.community,
884         });
885         toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
886       } else {
887         mui.community_blocks = mui.community_blocks.filter(
888           i => i.community.id != data.community_view.community.id
889         );
890         toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
891       }
892       return Some(mui.community_blocks);
893     },
894     none: None,
895   });
896 }
897
898 export function createCommentLikeRes(
899   data: CommentView,
900   comments: CommentView[]
901 ) {
902   let found = comments.find(c => c.comment.id === data.comment.id);
903   if (found) {
904     found.counts.score = data.counts.score;
905     found.counts.upvotes = data.counts.upvotes;
906     found.counts.downvotes = data.counts.downvotes;
907     if (data.my_vote !== null) {
908       found.my_vote = data.my_vote;
909     }
910   }
911 }
912
913 export function createPostLikeFindRes(data: PostView, posts: PostView[]) {
914   let found = posts.find(p => p.post.id == data.post.id);
915   if (found) {
916     createPostLikeRes(data, found);
917   }
918 }
919
920 export function createPostLikeRes(data: PostView, post_view: PostView) {
921   if (post_view) {
922     post_view.counts.score = data.counts.score;
923     post_view.counts.upvotes = data.counts.upvotes;
924     post_view.counts.downvotes = data.counts.downvotes;
925     if (data.my_vote !== null) {
926       post_view.my_vote = data.my_vote;
927     }
928   }
929 }
930
931 export function editPostFindRes(data: PostView, posts: PostView[]) {
932   let found = posts.find(p => p.post.id == data.post.id);
933   if (found) {
934     editPostRes(data, found);
935   }
936 }
937
938 export function editPostRes(data: PostView, post: PostView) {
939   if (post) {
940     post.post.url = data.post.url;
941     post.post.name = data.post.name;
942     post.post.nsfw = data.post.nsfw;
943     post.post.deleted = data.post.deleted;
944     post.post.removed = data.post.removed;
945     post.post.stickied = data.post.stickied;
946     post.post.body = data.post.body;
947     post.post.locked = data.post.locked;
948     post.saved = data.saved;
949   }
950 }
951
952 export function updatePostReportRes(
953   data: PostReportView,
954   reports: PostReportView[]
955 ) {
956   let found = reports.find(p => p.post_report.id == data.post_report.id);
957   if (found) {
958     found.post_report = data.post_report;
959   }
960 }
961
962 export function updateCommentReportRes(
963   data: CommentReportView,
964   reports: CommentReportView[]
965 ) {
966   let found = reports.find(c => c.comment_report.id == data.comment_report.id);
967   if (found) {
968     found.comment_report = data.comment_report;
969   }
970 }
971
972 export function updateRegistrationApplicationRes(
973   data: RegistrationApplicationView,
974   applications: RegistrationApplicationView[]
975 ) {
976   let found = applications.find(
977     ra => ra.registration_application.id == data.registration_application.id
978   );
979   if (found) {
980     found.registration_application = data.registration_application;
981     found.admin = data.admin;
982     found.creator_local_user = data.creator_local_user;
983   }
984 }
985
986 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
987   let nodes: CommentNodeI[] = [];
988   for (let comment of comments) {
989     nodes.push({ comment_view: comment, children: [], depth: 0 });
990   }
991   return nodes;
992 }
993
994 export function convertCommentSortType(sort: SortType): CommentSortType {
995   if (
996     sort == SortType.TopAll ||
997     sort == SortType.TopDay ||
998     sort == SortType.TopWeek ||
999     sort == SortType.TopMonth ||
1000     sort == SortType.TopYear
1001   ) {
1002     return CommentSortType.Top;
1003   } else if (sort == SortType.New) {
1004     return CommentSortType.New;
1005   } else if (sort == SortType.Hot || sort == SortType.Active) {
1006     return CommentSortType.Hot;
1007   } else {
1008     return CommentSortType.Hot;
1009   }
1010 }
1011
1012 export function buildCommentsTree(
1013   comments: CommentView[],
1014   parentComment: boolean
1015 ): CommentNodeI[] {
1016   let map = new Map<number, CommentNodeI>();
1017   let depthOffset = !parentComment
1018     ? 0
1019     : getDepthFromComment(comments[0].comment);
1020
1021   for (let comment_view of comments) {
1022     let node: CommentNodeI = {
1023       comment_view: comment_view,
1024       children: [],
1025       depth: getDepthFromComment(comment_view.comment) - depthOffset,
1026     };
1027     map.set(comment_view.comment.id, { ...node });
1028   }
1029
1030   let tree: CommentNodeI[] = [];
1031
1032   // if its a parent comment fetch, then push the first comment to the top node.
1033   if (parentComment) {
1034     tree.push(map.get(comments[0].comment.id));
1035   }
1036
1037   for (let comment_view of comments) {
1038     let child = map.get(comment_view.comment.id);
1039     let parent_id = getCommentParentId(comment_view.comment);
1040     parent_id.match({
1041       some: parentId => {
1042         let parent = map.get(parentId);
1043         // Necessary because blocked comment might not exist
1044         if (parent) {
1045           parent.children.push(child);
1046         }
1047       },
1048       none: () => {
1049         if (!parentComment) {
1050           tree.push(child);
1051         }
1052       },
1053     });
1054   }
1055
1056   return tree;
1057 }
1058
1059 export function getCommentParentId(comment: CommentI): Option<number> {
1060   let split = comment.path.split(".");
1061   // remove the 0
1062   split.shift();
1063
1064   if (split.length > 1) {
1065     return Some(Number(split[split.length - 2]));
1066   } else {
1067     return None;
1068   }
1069 }
1070
1071 export function getDepthFromComment(comment: CommentI): number {
1072   return comment.path.split(".").length - 2;
1073 }
1074
1075 export function insertCommentIntoTree(
1076   tree: CommentNodeI[],
1077   cv: CommentView,
1078   parentComment: boolean
1079 ) {
1080   // Building a fake node to be used for later
1081   let node: CommentNodeI = {
1082     comment_view: cv,
1083     children: [],
1084     depth: 0,
1085   };
1086
1087   getCommentParentId(cv.comment).match({
1088     some: parentId => {
1089       let parentComment = searchCommentTree(tree, parentId);
1090       parentComment.match({
1091         some: pComment => {
1092           node.depth = pComment.depth + 1;
1093           pComment.children.unshift(node);
1094         },
1095         none: void 0,
1096       });
1097     },
1098     none: () => {
1099       if (!parentComment) {
1100         tree.unshift(node);
1101       }
1102     },
1103   });
1104 }
1105
1106 export function searchCommentTree(
1107   tree: CommentNodeI[],
1108   id: number
1109 ): Option<CommentNodeI> {
1110   for (let node of tree) {
1111     if (node.comment_view.comment.id === id) {
1112       return Some(node);
1113     }
1114
1115     for (const child of node.children) {
1116       let res = searchCommentTree([child], id);
1117
1118       if (res.isSome()) {
1119         return res;
1120       }
1121     }
1122   }
1123   return None;
1124 }
1125
1126 export const colorList: string[] = [
1127   hsl(0),
1128   hsl(50),
1129   hsl(100),
1130   hsl(150),
1131   hsl(200),
1132   hsl(250),
1133   hsl(300),
1134 ];
1135
1136 function hsl(num: number) {
1137   return `hsla(${num}, 35%, 50%, 1)`;
1138 }
1139
1140 export function hostname(url: string): string {
1141   let cUrl = new URL(url);
1142   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1143 }
1144
1145 export function validTitle(title?: string): boolean {
1146   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1147   if (!title || title.length < 3) return true;
1148
1149   const regex = new RegExp(/.*\S.*/, "g");
1150
1151   return regex.test(title);
1152 }
1153
1154 export function siteBannerCss(banner: string): string {
1155   return ` \
1156     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1157     background-attachment: fixed; \
1158     background-position: top; \
1159     background-repeat: no-repeat; \
1160     background-size: 100% cover; \
1161
1162     width: 100%; \
1163     max-height: 100vh; \
1164     `;
1165 }
1166
1167 export function isBrowser() {
1168   return typeof window !== "undefined";
1169 }
1170
1171 export function setIsoData<Type1, Type2, Type3, Type4, Type5>(
1172   context: any,
1173   cls1?: ClassConstructor<Type1>,
1174   cls2?: ClassConstructor<Type2>,
1175   cls3?: ClassConstructor<Type3>,
1176   cls4?: ClassConstructor<Type4>,
1177   cls5?: ClassConstructor<Type5>
1178 ): IsoData {
1179   // If its the browser, you need to deserialize the data from the window
1180   if (isBrowser()) {
1181     let json = window.isoData;
1182     let routeData = json.routeData;
1183     let routeDataOut: any[] = [];
1184
1185     // Can't do array looping because of specific type constructor required
1186     if (routeData[0]) {
1187       routeDataOut[0] = convertWindowJson(cls1, routeData[0]);
1188     }
1189     if (routeData[1]) {
1190       routeDataOut[1] = convertWindowJson(cls2, routeData[1]);
1191     }
1192     if (routeData[2]) {
1193       routeDataOut[2] = convertWindowJson(cls3, routeData[2]);
1194     }
1195     if (routeData[3]) {
1196       routeDataOut[3] = convertWindowJson(cls4, routeData[3]);
1197     }
1198     if (routeData[4]) {
1199       routeDataOut[4] = convertWindowJson(cls5, routeData[4]);
1200     }
1201     let site_res = convertWindowJson(GetSiteResponse, json.site_res);
1202
1203     let isoData: IsoData = {
1204       path: json.path,
1205       site_res,
1206       routeData: routeDataOut,
1207     };
1208     return isoData;
1209   } else return context.router.staticContext;
1210 }
1211
1212 /**
1213  * Necessary since window ISOData can't store function types like Option
1214  */
1215 export function convertWindowJson<T>(cls: ClassConstructor<T>, data: any): T {
1216   return deserialize(cls, serialize(data));
1217 }
1218
1219 export function wsSubscribe(parseMessage: any): Subscription {
1220   if (isBrowser()) {
1221     return WebSocketService.Instance.subject
1222       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
1223       .subscribe(
1224         msg => parseMessage(msg),
1225         err => console.error(err),
1226         () => console.log("complete")
1227       );
1228   } else {
1229     return null;
1230   }
1231 }
1232
1233 moment.updateLocale("en", {
1234   relativeTime: {
1235     future: "in %s",
1236     past: "%s ago",
1237     s: "<1m",
1238     ss: "%ds",
1239     m: "1m",
1240     mm: "%dm",
1241     h: "1h",
1242     hh: "%dh",
1243     d: "1d",
1244     dd: "%dd",
1245     w: "1w",
1246     ww: "%dw",
1247     M: "1M",
1248     MM: "%dM",
1249     y: "1Y",
1250     yy: "%dY",
1251   },
1252 });
1253
1254 export function saveScrollPosition(context: any) {
1255   let path: string = context.router.route.location.pathname;
1256   let y = window.scrollY;
1257   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
1258 }
1259
1260 export function restoreScrollPosition(context: any) {
1261   let path: string = context.router.route.location.pathname;
1262   let y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
1263   window.scrollTo(0, y);
1264 }
1265
1266 export function showLocal(isoData: IsoData): boolean {
1267   return isoData.site_res.federated_instances
1268     .map(f => f.linked.length > 0)
1269     .unwrapOr(false);
1270 }
1271
1272 interface ChoicesValue {
1273   value: string;
1274   label: string;
1275 }
1276
1277 export function communityToChoice(cv: CommunityView): ChoicesValue {
1278   let choice: ChoicesValue = {
1279     value: cv.community.id.toString(),
1280     label: communitySelectName(cv),
1281   };
1282   return choice;
1283 }
1284
1285 export function personToChoice(pvs: PersonViewSafe): ChoicesValue {
1286   let choice: ChoicesValue = {
1287     value: pvs.person.id.toString(),
1288     label: personSelectName(pvs),
1289   };
1290   return choice;
1291 }
1292
1293 export async function fetchCommunities(q: string) {
1294   let form = new Search({
1295     q,
1296     type_: Some(SearchType.Communities),
1297     sort: Some(SortType.TopAll),
1298     listing_type: Some(ListingType.All),
1299     page: Some(1),
1300     limit: Some(fetchLimit),
1301     community_id: None,
1302     community_name: None,
1303     creator_id: None,
1304     auth: auth(false).ok(),
1305   });
1306   let client = new LemmyHttp(httpBase);
1307   return client.search(form);
1308 }
1309
1310 export async function fetchUsers(q: string) {
1311   let form = new Search({
1312     q,
1313     type_: Some(SearchType.Users),
1314     sort: Some(SortType.TopAll),
1315     listing_type: Some(ListingType.All),
1316     page: Some(1),
1317     limit: Some(fetchLimit),
1318     community_id: None,
1319     community_name: None,
1320     creator_id: None,
1321     auth: auth(false).ok(),
1322   });
1323   let client = new LemmyHttp(httpBase);
1324   return client.search(form);
1325 }
1326
1327 export const choicesConfig = {
1328   shouldSort: false,
1329   searchResultLimit: fetchLimit,
1330   classNames: {
1331     containerOuter: "choices",
1332     containerInner: "choices__inner bg-secondary border-0",
1333     input: "form-control",
1334     inputCloned: "choices__input--cloned",
1335     list: "choices__list",
1336     listItems: "choices__list--multiple",
1337     listSingle: "choices__list--single",
1338     listDropdown: "choices__list--dropdown",
1339     item: "choices__item bg-secondary",
1340     itemSelectable: "choices__item--selectable",
1341     itemDisabled: "choices__item--disabled",
1342     itemChoice: "choices__item--choice",
1343     placeholder: "choices__placeholder",
1344     group: "choices__group",
1345     groupHeading: "choices__heading",
1346     button: "choices__button",
1347     activeState: "is-active",
1348     focusState: "is-focused",
1349     openState: "is-open",
1350     disabledState: "is-disabled",
1351     highlightedState: "text-info",
1352     selectedState: "text-info",
1353     flippedState: "is-flipped",
1354     loadingState: "is-loading",
1355     noResults: "has-no-results",
1356     noChoices: "has-no-choices",
1357   },
1358 };
1359
1360 export function communitySelectName(cv: CommunityView): string {
1361   return cv.community.local
1362     ? cv.community.title
1363     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
1364 }
1365
1366 export function personSelectName(pvs: PersonViewSafe): string {
1367   let pName = pvs.person.display_name.unwrapOr(pvs.person.name);
1368   return pvs.person.local ? pName : `${hostname(pvs.person.actor_id)}/${pName}`;
1369 }
1370
1371 export function initializeSite(site: GetSiteResponse) {
1372   UserService.Instance.myUserInfo = site.my_user;
1373   i18n.changeLanguage(getLanguages()[0]);
1374 }
1375
1376 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
1377   maximumSignificantDigits: 3,
1378   //@ts-ignore
1379   notation: "compact",
1380   compactDisplay: "short",
1381 });
1382
1383 export function numToSI(value: number): string {
1384   return SHORTNUM_SI_FORMAT.format(value);
1385 }
1386
1387 export function isBanned(ps: PersonSafe): boolean {
1388   let expires = ps.ban_expires;
1389   // Add Z to convert from UTC date
1390   // TODO this check probably isn't necessary anymore
1391   if (expires.isSome()) {
1392     if (ps.banned && new Date(expires.unwrap() + "Z") > new Date()) {
1393       return true;
1394     } else {
1395       return false;
1396     }
1397   } else {
1398     return ps.banned;
1399   }
1400 }
1401
1402 export function pushNotNull(array: any[], new_item?: any) {
1403   if (new_item) {
1404     array.push(...new_item);
1405   }
1406 }
1407
1408 export function auth(throwErr = true): Result<string, string> {
1409   return UserService.Instance.auth(throwErr);
1410 }
1411
1412 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
1413   return siteRes.site_view.map(s => s.site.enable_downvotes).unwrapOr(true);
1414 }
1415
1416 export function enableNsfw(siteRes: GetSiteResponse): boolean {
1417   return siteRes.site_view.map(s => s.site.enable_nsfw).unwrapOr(false);
1418 }
1419
1420 export function postToCommentSortType(sort: SortType): CommentSortType {
1421   if ([SortType.Active, SortType.Hot].includes(sort)) {
1422     return CommentSortType.Hot;
1423   } else if ([SortType.New, SortType.NewComments].includes(sort)) {
1424     return CommentSortType.New;
1425   } else if (sort == SortType.Old) {
1426     return CommentSortType.Old;
1427   } else {
1428     return CommentSortType.Top;
1429   }
1430 }