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