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