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