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