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