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