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