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