]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
Fix guide URL, fixes link breakages in moved docs. This should resolve Issue 108.
[lemmy-ui.git] / src / shared / utils.ts
1 import 'moment/locale/es';
2 import 'moment/locale/el';
3 import 'moment/locale/eu';
4 import 'moment/locale/eo';
5 import 'moment/locale/de';
6 import 'moment/locale/zh-cn';
7 import 'moment/locale/fr';
8 import 'moment/locale/sv';
9 import 'moment/locale/ru';
10 import 'moment/locale/nl';
11 import 'moment/locale/it';
12 import 'moment/locale/fi';
13 import 'moment/locale/ca';
14 import 'moment/locale/fa';
15 import 'moment/locale/pl';
16 import 'moment/locale/pt-br';
17 import 'moment/locale/ja';
18 import 'moment/locale/ka';
19 import 'moment/locale/hi';
20 import 'moment/locale/gl';
21 import 'moment/locale/tr';
22 import 'moment/locale/hu';
23 import 'moment/locale/uk';
24 import 'moment/locale/sq';
25 import 'moment/locale/km';
26 import 'moment/locale/ga';
27 import 'moment/locale/sr';
28 import 'moment/locale/ko';
29 import 'moment/locale/da';
30
31 import {
32   UserOperation,
33   Comment,
34   CommentNode as CommentNodeI,
35   Post,
36   PrivateMessage,
37   User,
38   SortType,
39   ListingType,
40   SearchType,
41   WebSocketResponse,
42   WebSocketJsonResponse,
43   SearchForm,
44   SearchResponse,
45   CommentResponse,
46   PostResponse,
47 } from 'lemmy-js-client';
48
49 import { CommentSortType, DataType, IsoData } from './interfaces';
50 import { UserService, WebSocketService } from './services';
51
52 var Tribute;
53 if (isBrowser()) {
54   Tribute = require('tributejs');
55 }
56 import markdown_it from 'markdown-it';
57 import markdown_it_sub from 'markdown-it-sub';
58 import markdown_it_sup from 'markdown-it-sup';
59 import markdownitEmoji from 'markdown-it-emoji/light';
60 import markdown_it_container from 'markdown-it-container';
61 import emojiShortName from 'emoji-short-name';
62 import Toastify from 'toastify-js';
63 import tippy from 'tippy.js';
64 import moment from 'moment';
65 import { Subscription } from 'rxjs';
66 import { retryWhen, delay, take } from 'rxjs/operators';
67
68 export const favIconUrl = '/static/assets/favicon.svg';
69 export const favIconPngUrl = '/static/assets/apple-touch-icon.png';
70 // TODO
71 // export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
72 export const defaultFavIcon = 'test';
73 export const repoUrl = 'https://github.com/LemmyNet';
74 export const joinLemmyUrl = 'https://join.lemmy.ml';
75 export const supportLemmyUrl = 'https://join.lemmy.ml/sponsors';
76 export const helpGuideUrl = '/docs/about/guide.html';
77 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
78 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
79 export const archiveUrl = 'https://archive.is';
80 export const elementUrl = 'https://element.io/';
81
82 export const postRefetchSeconds: number = 60 * 1000;
83 export const fetchLimit: number = 20;
84 export const mentionDropdownFetchLimit = 10;
85
86 export const languages = [
87   { code: 'ca', name: 'Català' },
88   { code: 'en', name: 'English' },
89   { code: 'el', name: 'Ελληνικά' },
90   { code: 'eu', name: 'Euskara' },
91   { code: 'eo', name: 'Esperanto' },
92   { code: 'es', name: 'Español' },
93   { code: 'da', name: 'Dansk' },
94   { code: 'de', name: 'Deutsch' },
95   { code: 'ga', name: 'Gaeilge' },
96   { code: 'gl', name: 'Galego' },
97   { code: 'hu', name: 'Magyar Nyelv' },
98   { code: 'ka', name: 'ქართული ენა' },
99   { code: 'ko', name: '한국어' },
100   { code: 'km', name: 'ភាសាខ្មែរ' },
101   { code: 'hi', name: 'मानक हिन्दी' },
102   { code: 'fa', name: 'فارسی' },
103   { code: 'ja', name: '日本語' },
104   { code: 'pl', name: 'Polski' },
105   { code: 'pt_BR', name: 'Português Brasileiro' },
106   { code: 'zh', name: '中文' },
107   { code: 'fi', name: 'Suomi' },
108   { code: 'fr', name: 'Français' },
109   { code: 'sv', name: 'Svenska' },
110   { code: 'sq', name: 'Shqip' },
111   { code: 'sr_Latn', name: 'srpski' },
112   { code: 'tr', name: 'Türkçe' },
113   { code: 'uk', name: 'Українська Mова' },
114   { code: 'ru', name: 'Русский' },
115   { code: 'nl', name: 'Nederlands' },
116   { code: 'it', name: 'Italiano' },
117 ];
118
119 export const themes = [
120   'litera',
121   'materia',
122   'minty',
123   'solar',
124   'united',
125   'cyborg',
126   'darkly',
127   'journal',
128   'sketchy',
129   'vaporwave',
130   'vaporwave-dark',
131   'i386',
132   'litely',
133 ];
134
135 const DEFAULT_ALPHABET =
136   'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
137
138 function getRandomCharFromAlphabet(alphabet: string): string {
139   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
140 }
141
142 export function randomStr(
143   idDesiredLength: number = 20,
144   alphabet = DEFAULT_ALPHABET
145 ): string {
146   /**
147    * Create n-long array and map it to random chars from given alphabet.
148    * Then join individual chars as string
149    */
150   return Array.from({ length: idDesiredLength })
151     .map(() => {
152       return getRandomCharFromAlphabet(alphabet);
153     })
154     .join('');
155 }
156
157 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
158   let opStr: string = msg.op;
159   return {
160     op: UserOperation[opStr],
161     data: msg.data,
162   };
163 }
164
165 export const md = new markdown_it({
166   html: false,
167   linkify: true,
168   typographer: true,
169 })
170   .use(markdown_it_sub)
171   .use(markdown_it_sup)
172   .use(markdown_it_container, 'spoiler', {
173     validate: function (params: any) {
174       return params.trim().match(/^spoiler\s+(.*)$/);
175     },
176
177     render: function (tokens: any, idx: any) {
178       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
179
180       if (tokens[idx].nesting === 1) {
181         // opening tag
182         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
183       } else {
184         // closing tag
185         return '</details>\n';
186       }
187     },
188   })
189   .use(markdownitEmoji, {
190     defs: objectFlip(emojiShortName),
191   });
192
193 export function hotRankComment(comment: Comment): number {
194   return hotRank(comment.score, comment.published);
195 }
196
197 export function hotRankPost(post: Post): number {
198   return hotRank(post.score, post.newest_activity_time);
199 }
200
201 export function hotRank(score: number, timeStr: string): number {
202   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
203   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
204   let now: Date = new Date();
205   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
206
207   let rank =
208     (10000 * Math.log10(Math.max(1, 3 + score))) /
209     Math.pow(hoursElapsed + 2, 1.8);
210
211   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
212
213   return rank;
214 }
215
216 export function mdToHtml(text: string) {
217   return { __html: md.render(text) };
218 }
219
220 export function getUnixTime(text: string): number {
221   return text ? new Date(text).getTime() / 1000 : undefined;
222 }
223
224 export function addTypeInfo<T>(
225   arr: T[],
226   name: string
227 ): { type_: string; data: T }[] {
228   return arr.map(e => {
229     return { type_: name, data: e };
230   });
231 }
232
233 export function canMod(
234   user: User,
235   modIds: number[],
236   creator_id: number,
237   onSelf: boolean = false
238 ): boolean {
239   // You can do moderator actions only on the mods added after you.
240   if (user) {
241     let yourIndex = modIds.findIndex(id => id == user.id);
242     if (yourIndex == -1) {
243       return false;
244     } else {
245       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
246       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
247       return !modIds.includes(creator_id);
248     }
249   } else {
250     return false;
251   }
252 }
253
254 export function isMod(modIds: number[], creator_id: number): boolean {
255   return modIds.includes(creator_id);
256 }
257
258 const imageRegex = new RegExp(
259   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
260 );
261 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
262
263 export function isImage(url: string) {
264   return imageRegex.test(url);
265 }
266
267 export function isVideo(url: string) {
268   return videoRegex.test(url);
269 }
270
271 export function validURL(str: string) {
272   return !!new URL(str);
273 }
274
275 export function communityRSSUrl(actorId: string, sort: string): string {
276   let url = new URL(actorId);
277   return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
278 }
279
280 export function validEmail(email: string) {
281   let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
282   return re.test(String(email).toLowerCase());
283 }
284
285 export function capitalizeFirstLetter(str: string): string {
286   return str.charAt(0).toUpperCase() + str.slice(1);
287 }
288
289 export function routeSortTypeToEnum(sort: string): SortType {
290   return SortType[sort];
291 }
292
293 export function routeListingTypeToEnum(type: string): ListingType {
294   return ListingType[type];
295 }
296
297 export function routeDataTypeToEnum(type: string): DataType {
298   return DataType[capitalizeFirstLetter(type)];
299 }
300
301 export function routeSearchTypeToEnum(type: string): SearchType {
302   return SearchType[type];
303 }
304
305 export async function getPageTitle(url: string) {
306   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
307   let title = await res.title;
308   return title;
309 }
310
311 export function debounce(
312   func: any,
313   wait: number = 1000,
314   immediate: boolean = false
315 ) {
316   // 'private' variable for instance
317   // The returned function will be able to reference this due to closure.
318   // Each call to the returned function will share this common timer.
319   let timeout: any;
320
321   // Calling debounce returns a new anonymous function
322   return function () {
323     // reference the context and args for the setTimeout function
324     var context = this,
325       args = arguments;
326
327     // Should the function be called now? If immediate is true
328     //   and not already in a timeout then the answer is: Yes
329     var callNow = immediate && !timeout;
330
331     // This is the basic debounce behaviour where you can call this
332     //   function several times, but it will only execute once
333     //   [before or after imposing a delay].
334     //   Each time the returned function is called, the timer starts over.
335     clearTimeout(timeout);
336
337     // Set the new timeout
338     timeout = setTimeout(function () {
339       // Inside the timeout function, clear the timeout variable
340       // which will let the next execution run when in 'immediate' mode
341       timeout = null;
342
343       // Check if the function already ran with the immediate flag
344       if (!immediate) {
345         // Call the original function with apply
346         // apply lets you define the 'this' object as well as the arguments
347         //    (both captured before setTimeout)
348         func.apply(context, args);
349       }
350     }, wait);
351
352     // Immediate mode and no wait timer? Execute the function..
353     if (callNow) func.apply(context, args);
354   };
355 }
356
357 // TODO
358 export function getLanguage(override?: string): string {
359   let user = UserService.Instance.user;
360   let lang = override || (user && user.lang ? user.lang : 'browser');
361
362   if (lang == 'browser' && isBrowser()) {
363     return getBrowserLanguage();
364   } else {
365     return lang;
366   }
367 }
368
369 // TODO
370 export function getBrowserLanguage(): string {
371   return navigator.language;
372 }
373
374 export function getMomentLanguage(): string {
375   let lang = getLanguage();
376   if (lang.startsWith('zh')) {
377     lang = 'zh-cn';
378   } else if (lang.startsWith('sv')) {
379     lang = 'sv';
380   } else if (lang.startsWith('fr')) {
381     lang = 'fr';
382   } else if (lang.startsWith('de')) {
383     lang = 'de';
384   } else if (lang.startsWith('ru')) {
385     lang = 'ru';
386   } else if (lang.startsWith('es')) {
387     lang = 'es';
388   } else if (lang.startsWith('eo')) {
389     lang = 'eo';
390   } else if (lang.startsWith('nl')) {
391     lang = 'nl';
392   } else if (lang.startsWith('it')) {
393     lang = 'it';
394   } else if (lang.startsWith('fi')) {
395     lang = 'fi';
396   } else if (lang.startsWith('ca')) {
397     lang = 'ca';
398   } else if (lang.startsWith('fa')) {
399     lang = 'fa';
400   } else if (lang.startsWith('pl')) {
401     lang = 'pl';
402   } else if (lang.startsWith('pt')) {
403     lang = 'pt-br';
404   } else if (lang.startsWith('ja')) {
405     lang = 'ja';
406   } else if (lang.startsWith('ka')) {
407     lang = 'ka';
408   } else if (lang.startsWith('hi')) {
409     lang = 'hi';
410   } else if (lang.startsWith('el')) {
411     lang = 'el';
412   } else if (lang.startsWith('eu')) {
413     lang = 'eu';
414   } else if (lang.startsWith('gl')) {
415     lang = 'gl';
416   } else if (lang.startsWith('tr')) {
417     lang = 'tr';
418   } else if (lang.startsWith('hu')) {
419     lang = 'hu';
420   } else if (lang.startsWith('uk')) {
421     lang = 'uk';
422   } else if (lang.startsWith('sq')) {
423     lang = 'sq';
424   } else if (lang.startsWith('km')) {
425     lang = 'km';
426   } else if (lang.startsWith('ga')) {
427     lang = 'ga';
428   } else if (lang.startsWith('sr')) {
429     lang = 'sr';
430   } else if (lang.startsWith('ko')) {
431     lang = 'ko';
432   } else if (lang.startsWith('da')) {
433     lang = 'da';
434   } else {
435     lang = 'en';
436   }
437   return lang;
438 }
439
440 export function setTheme(theme: string, forceReload: boolean = false) {
441   if (!isBrowser()) {
442     return;
443   }
444   if (theme === 'browser' && !forceReload) {
445     return;
446   }
447   // This is only run on a force reload
448   if (theme == 'browser') {
449     theme = 'darkly';
450   }
451
452   // Unload all the other themes
453   for (var i = 0; i < themes.length; i++) {
454     let styleSheet = document.getElementById(themes[i]);
455     if (styleSheet) {
456       styleSheet.setAttribute('disabled', 'disabled');
457     }
458   }
459
460   document
461     .getElementById('default-light')
462     ?.setAttribute('disabled', 'disabled');
463   document.getElementById('default-dark')?.setAttribute('disabled', 'disabled');
464
465   // Load the theme dynamically
466   let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
467   loadCss(theme, cssLoc);
468   document.getElementById(theme).removeAttribute('disabled');
469 }
470
471 export function loadCss(id: string, loc: string) {
472   if (!document.getElementById(id)) {
473     var head = document.getElementsByTagName('head')[0];
474     var link = document.createElement('link');
475     link.id = id;
476     link.rel = 'stylesheet';
477     link.type = 'text/css';
478     link.href = loc;
479     link.media = 'all';
480     head.appendChild(link);
481   }
482 }
483
484 export function objectFlip(obj: any) {
485   const ret = {};
486   Object.keys(obj).forEach(key => {
487     ret[obj[key]] = key;
488   });
489   return ret;
490 }
491
492 export function showAvatars(): boolean {
493   return (
494     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
495     !UserService.Instance.user
496   );
497 }
498
499 export function isCakeDay(published: string): boolean {
500   // moment(undefined) or moment.utc(undefined) returns the current date/time
501   // moment(null) or moment.utc(null) returns null
502   const userCreationDate = moment.utc(published || null).local();
503   const currentDate = moment(new Date());
504
505   return (
506     userCreationDate.date() === currentDate.date() &&
507     userCreationDate.month() === currentDate.month() &&
508     userCreationDate.year() !== currentDate.year()
509   );
510 }
511
512 export function isCommentType(
513   item: Comment | PrivateMessage | Post
514 ): item is Comment {
515   return (
516     (item as Comment).community_id !== undefined &&
517     (item as Comment).content !== undefined
518   );
519 }
520
521 export function isPostType(
522   item: Comment | PrivateMessage | Post
523 ): item is Post {
524   return (item as Post).stickied !== undefined;
525 }
526
527 export function toast(text: string, background: string = 'success') {
528   if (isBrowser()) {
529     let backgroundColor = `var(--${background})`;
530     Toastify({
531       text: text,
532       backgroundColor: backgroundColor,
533       gravity: 'bottom',
534       position: 'left',
535     }).showToast();
536   }
537 }
538
539 export function pictrsDeleteToast(
540   clickToDeleteText: string,
541   deletePictureText: string,
542   deleteUrl: string
543 ) {
544   if (isBrowser()) {
545     let backgroundColor = `var(--light)`;
546     let toast = Toastify({
547       text: clickToDeleteText,
548       backgroundColor: backgroundColor,
549       gravity: 'top',
550       position: 'right',
551       duration: 10000,
552       onClick: () => {
553         if (toast) {
554           window.location.replace(deleteUrl);
555           alert(deletePictureText);
556           toast.hideToast();
557         }
558       },
559       close: true,
560     }).showToast();
561   }
562 }
563
564 interface NotifyInfo {
565   name: string;
566   icon: string;
567   link: string;
568   body: string;
569 }
570
571 export function messageToastify(info: NotifyInfo, router: any) {
572   if (isBrowser()) {
573     let htmlBody = info.body ? md.render(info.body) : '';
574     let backgroundColor = `var(--light)`;
575
576     let toast = Toastify({
577       text: `${htmlBody}<br />${info.name}`,
578       avatar: info.icon,
579       backgroundColor: backgroundColor,
580       className: 'text-dark',
581       close: true,
582       gravity: 'top',
583       position: 'right',
584       duration: 5000,
585       onClick: () => {
586         if (toast) {
587           toast.hideToast();
588           router.history.push(info.link);
589         }
590       },
591     }).showToast();
592   }
593 }
594
595 export function notifyPost(post: Post, router: any) {
596   let info: NotifyInfo = {
597     name: post.community_name,
598     icon: post.community_icon ? post.community_icon : defaultFavIcon,
599     link: `/post/${post.id}`,
600     body: post.name,
601   };
602   notify(info, router);
603 }
604
605 export function notifyComment(comment: Comment, router: any) {
606   let info: NotifyInfo = {
607     name: comment.creator_name,
608     icon: comment.creator_avatar ? comment.creator_avatar : defaultFavIcon,
609     link: `/post/${comment.post_id}/comment/${comment.id}`,
610     body: comment.content,
611   };
612   notify(info, router);
613 }
614
615 export function notifyPrivateMessage(pm: PrivateMessage, router: any) {
616   let info: NotifyInfo = {
617     name: pm.creator_name,
618     icon: pm.creator_avatar ? pm.creator_avatar : defaultFavIcon,
619     link: `/inbox`,
620     body: pm.content,
621   };
622   notify(info, router);
623 }
624
625 function notify(info: NotifyInfo, router: any) {
626   messageToastify(info, router);
627
628   if (Notification.permission !== 'granted') Notification.requestPermission();
629   else {
630     var notification = new Notification(info.name, {
631       icon: info.icon,
632       body: info.body,
633     });
634
635     notification.onclick = () => {
636       event.preventDefault();
637       router.history.push(info.link);
638     };
639   }
640 }
641
642 export function setupTribute() {
643   return new Tribute({
644     noMatchTemplate: function () {
645       return '';
646     },
647     collection: [
648       // Emojis
649       {
650         trigger: ':',
651         menuItemTemplate: (item: any) => {
652           let shortName = `:${item.original.key}:`;
653           return `${item.original.val} ${shortName}`;
654         },
655         selectTemplate: (item: any) => {
656           return `:${item.original.key}:`;
657         },
658         values: Object.entries(emojiShortName).map(e => {
659           return { key: e[1], val: e[0] };
660         }),
661         allowSpaces: false,
662         autocompleteMode: true,
663         // TODO
664         // menuItemLimit: mentionDropdownFetchLimit,
665         menuShowMinLength: 2,
666       },
667       // Users
668       {
669         trigger: '@',
670         selectTemplate: (item: any) => {
671           let link = item.original.local
672             ? `[${item.original.key}](/u/${item.original.name})`
673             : `[${item.original.key}](/user/${item.original.id})`;
674           return link;
675         },
676         values: (text: string, cb: any) => {
677           userSearch(text, (users: any) => cb(users));
678         },
679         allowSpaces: false,
680         autocompleteMode: true,
681         // TODO
682         // menuItemLimit: mentionDropdownFetchLimit,
683         menuShowMinLength: 2,
684       },
685
686       // Communities
687       {
688         trigger: '!',
689         selectTemplate: (item: any) => {
690           let link = item.original.local
691             ? `[${item.original.key}](/c/${item.original.name})`
692             : `[${item.original.key}](/community/${item.original.id})`;
693           return link;
694         },
695         values: (text: string, cb: any) => {
696           communitySearch(text, (communities: any) => cb(communities));
697         },
698         allowSpaces: false,
699         autocompleteMode: true,
700         // TODO
701         // menuItemLimit: mentionDropdownFetchLimit,
702         menuShowMinLength: 2,
703       },
704     ],
705   });
706 }
707
708 var tippyInstance;
709 if (isBrowser()) {
710   tippyInstance = tippy('[data-tippy-content]');
711 }
712
713 export function setupTippy() {
714   if (isBrowser()) {
715     tippyInstance.forEach(e => e.destroy());
716     tippyInstance = tippy('[data-tippy-content]', {
717       delay: [500, 0],
718       // Display on "long press"
719       touch: ['hold', 500],
720     });
721   }
722 }
723
724 function userSearch(text: string, cb: any) {
725   if (text) {
726     let form: SearchForm = {
727       q: text,
728       type_: SearchType.Users,
729       sort: SortType.TopAll,
730       page: 1,
731       limit: mentionDropdownFetchLimit,
732     };
733
734     WebSocketService.Instance.search(form);
735
736     let userSub = WebSocketService.Instance.subject.subscribe(
737       msg => {
738         let res = wsJsonToRes(msg);
739         if (res.op == UserOperation.Search) {
740           let data = res.data as SearchResponse;
741           let users = data.users.map(u => {
742             return {
743               key: `@${u.name}@${hostname(u.actor_id)}`,
744               name: u.name,
745               local: u.local,
746               id: u.id,
747             };
748           });
749           cb(users);
750           userSub.unsubscribe();
751         }
752       },
753       err => console.error(err),
754       () => console.log('complete')
755     );
756   } else {
757     cb([]);
758   }
759 }
760
761 function communitySearch(text: string, cb: any) {
762   if (text) {
763     let form: SearchForm = {
764       q: text,
765       type_: SearchType.Communities,
766       sort: SortType.TopAll,
767       page: 1,
768       limit: mentionDropdownFetchLimit,
769     };
770
771     WebSocketService.Instance.search(form);
772
773     let communitySub = WebSocketService.Instance.subject.subscribe(
774       msg => {
775         let res = wsJsonToRes(msg);
776         if (res.op == UserOperation.Search) {
777           let data = res.data as SearchResponse;
778           let communities = data.communities.map(c => {
779             return {
780               key: `!${c.name}@${hostname(c.actor_id)}`,
781               name: c.name,
782               local: c.local,
783               id: c.id,
784             };
785           });
786           cb(communities);
787           communitySub.unsubscribe();
788         }
789       },
790       err => console.error(err),
791       () => console.log('complete')
792     );
793   } else {
794     cb([]);
795   }
796 }
797
798 export function getListingTypeFromProps(props: any): ListingType {
799   return props.match.params.listing_type
800     ? routeListingTypeToEnum(props.match.params.listing_type)
801     : UserService.Instance.user
802     ? Object.values(ListingType)[UserService.Instance.user.default_listing_type]
803     : ListingType.Local;
804 }
805
806 // TODO might need to add a user setting for this too
807 export function getDataTypeFromProps(props: any): DataType {
808   return props.match.params.data_type
809     ? routeDataTypeToEnum(props.match.params.data_type)
810     : DataType.Post;
811 }
812
813 export function getSortTypeFromProps(props: any): SortType {
814   return props.match.params.sort
815     ? routeSortTypeToEnum(props.match.params.sort)
816     : UserService.Instance.user
817     ? Object.values(SortType)[UserService.Instance.user.default_sort_type]
818     : SortType.Active;
819 }
820
821 export function getPageFromProps(props: any): number {
822   return props.match.params.page ? Number(props.match.params.page) : 1;
823 }
824
825 export function getRecipientIdFromProps(props: any): number {
826   return props.match.params.recipient_id
827     ? Number(props.match.params.recipient_id)
828     : 1;
829 }
830
831 export function getIdFromProps(props: any): number {
832   return Number(props.match.params.id);
833 }
834
835 export function getCommentIdFromProps(props: any): number {
836   return Number(props.match.params.comment_id);
837 }
838
839 export function getUsernameFromProps(props: any): string {
840   return props.match.params.username;
841 }
842
843 export function editCommentRes(data: CommentResponse, comments: Comment[]) {
844   let found = comments.find(c => c.id == data.comment.id);
845   if (found) {
846     found.content = data.comment.content;
847     found.updated = data.comment.updated;
848     found.removed = data.comment.removed;
849     found.deleted = data.comment.deleted;
850     found.upvotes = data.comment.upvotes;
851     found.downvotes = data.comment.downvotes;
852     found.score = data.comment.score;
853   }
854 }
855
856 export function saveCommentRes(data: CommentResponse, comments: Comment[]) {
857   let found = comments.find(c => c.id == data.comment.id);
858   if (found) {
859     found.saved = data.comment.saved;
860   }
861 }
862
863 export function createCommentLikeRes(
864   data: CommentResponse,
865   comments: Comment[]
866 ) {
867   let found: Comment = comments.find(c => c.id === data.comment.id);
868   if (found) {
869     found.score = data.comment.score;
870     found.upvotes = data.comment.upvotes;
871     found.downvotes = data.comment.downvotes;
872     if (data.comment.my_vote !== null) {
873       found.my_vote = data.comment.my_vote;
874     }
875   }
876 }
877
878 export function createPostLikeFindRes(data: PostResponse, posts: Post[]) {
879   let found = posts.find(c => c.id == data.post.id);
880   if (found) {
881     createPostLikeRes(data, found);
882   }
883 }
884
885 export function createPostLikeRes(data: PostResponse, post: Post) {
886   if (post) {
887     post.score = data.post.score;
888     post.upvotes = data.post.upvotes;
889     post.downvotes = data.post.downvotes;
890     if (data.post.my_vote !== null) {
891       post.my_vote = data.post.my_vote;
892     }
893   }
894 }
895
896 export function editPostFindRes(data: PostResponse, posts: Post[]) {
897   let found = posts.find(c => c.id == data.post.id);
898   if (found) {
899     editPostRes(data, found);
900   }
901 }
902
903 export function editPostRes(data: PostResponse, post: Post) {
904   if (post) {
905     post.url = data.post.url;
906     post.name = data.post.name;
907     post.nsfw = data.post.nsfw;
908     post.deleted = data.post.deleted;
909     post.removed = data.post.removed;
910     post.stickied = data.post.stickied;
911     post.body = data.post.body;
912     post.locked = data.post.locked;
913     post.saved = data.post.saved;
914   }
915 }
916
917 export function commentsToFlatNodes(comments: Comment[]): CommentNodeI[] {
918   let nodes: CommentNodeI[] = [];
919   for (let comment of comments) {
920     nodes.push({ comment: comment });
921   }
922   return nodes;
923 }
924
925 export function commentSort(tree: CommentNodeI[], sort: CommentSortType) {
926   // First, put removed and deleted comments at the bottom, then do your other sorts
927   if (sort == CommentSortType.Top) {
928     tree.sort(
929       (a, b) =>
930         +a.comment.removed - +b.comment.removed ||
931         +a.comment.deleted - +b.comment.deleted ||
932         b.comment.score - a.comment.score
933     );
934   } else if (sort == CommentSortType.New) {
935     tree.sort(
936       (a, b) =>
937         +a.comment.removed - +b.comment.removed ||
938         +a.comment.deleted - +b.comment.deleted ||
939         b.comment.published.localeCompare(a.comment.published)
940     );
941   } else if (sort == CommentSortType.Old) {
942     tree.sort(
943       (a, b) =>
944         +a.comment.removed - +b.comment.removed ||
945         +a.comment.deleted - +b.comment.deleted ||
946         a.comment.published.localeCompare(b.comment.published)
947     );
948   } else if (sort == CommentSortType.Hot) {
949     tree.sort(
950       (a, b) =>
951         +a.comment.removed - +b.comment.removed ||
952         +a.comment.deleted - +b.comment.deleted ||
953         hotRankComment(b.comment) - hotRankComment(a.comment)
954     );
955   }
956
957   // Go through the children recursively
958   for (let node of tree) {
959     if (node.children) {
960       commentSort(node.children, sort);
961     }
962   }
963 }
964
965 export function commentSortSortType(tree: CommentNodeI[], sort: SortType) {
966   commentSort(tree, convertCommentSortType(sort));
967 }
968
969 function convertCommentSortType(sort: SortType): CommentSortType {
970   if (
971     sort == SortType.TopAll ||
972     sort == SortType.TopDay ||
973     sort == SortType.TopWeek ||
974     sort == SortType.TopMonth ||
975     sort == SortType.TopYear
976   ) {
977     return CommentSortType.Top;
978   } else if (sort == SortType.New) {
979     return CommentSortType.New;
980   } else if (sort == SortType.Hot || sort == SortType.Active) {
981     return CommentSortType.Hot;
982   } else {
983     return CommentSortType.Hot;
984   }
985 }
986
987 export function postSort(
988   posts: Post[],
989   sort: SortType,
990   communityType: boolean
991 ) {
992   // First, put removed and deleted comments at the bottom, then do your other sorts
993   if (
994     sort == SortType.TopAll ||
995     sort == SortType.TopDay ||
996     sort == SortType.TopWeek ||
997     sort == SortType.TopMonth ||
998     sort == SortType.TopYear
999   ) {
1000     posts.sort(
1001       (a, b) =>
1002         +a.removed - +b.removed ||
1003         +a.deleted - +b.deleted ||
1004         (communityType && +b.stickied - +a.stickied) ||
1005         b.score - a.score
1006     );
1007   } else if (sort == SortType.New) {
1008     posts.sort(
1009       (a, b) =>
1010         +a.removed - +b.removed ||
1011         +a.deleted - +b.deleted ||
1012         (communityType && +b.stickied - +a.stickied) ||
1013         b.published.localeCompare(a.published)
1014     );
1015   } else if (sort == SortType.Hot) {
1016     posts.sort(
1017       (a, b) =>
1018         +a.removed - +b.removed ||
1019         +a.deleted - +b.deleted ||
1020         (communityType && +b.stickied - +a.stickied) ||
1021         b.hot_rank - a.hot_rank
1022     );
1023   } else if (sort == SortType.Active) {
1024     posts.sort(
1025       (a, b) =>
1026         +a.removed - +b.removed ||
1027         +a.deleted - +b.deleted ||
1028         (communityType && +b.stickied - +a.stickied) ||
1029         b.hot_rank_active - a.hot_rank_active
1030     );
1031   }
1032 }
1033
1034 export const colorList: string[] = [
1035   hsl(0),
1036   hsl(100),
1037   hsl(150),
1038   hsl(200),
1039   hsl(250),
1040   hsl(300),
1041 ];
1042
1043 function hsl(num: number) {
1044   return `hsla(${num}, 35%, 50%, 1)`;
1045 }
1046
1047 // function randomHsl() {
1048 //   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
1049 // }
1050
1051 export function previewLines(
1052   text: string,
1053   maxChars: number = 300,
1054   maxLines: number = 1
1055 ): string {
1056   return (
1057     text
1058       .slice(0, maxChars)
1059       .split('\n')
1060       // Use lines * 2 because markdown requires 2 lines
1061       .slice(0, maxLines * 2)
1062       .join('\n') + '...'
1063   );
1064 }
1065
1066 export function hostname(url: string): string {
1067   let cUrl = new URL(url);
1068   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1069 }
1070
1071 export function validTitle(title?: string): boolean {
1072   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1073   if (title === null || title.length < 3) return true;
1074
1075   const regex = new RegExp(/.*\S.*/, 'g');
1076
1077   return regex.test(title);
1078 }
1079
1080 export function siteBannerCss(banner: string): string {
1081   return ` \
1082     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1083     background-attachment: fixed; \
1084     background-position: top; \
1085     background-repeat: no-repeat; \
1086     background-size: 100% cover; \
1087
1088     width: 100%; \
1089     max-height: 100vh; \
1090     `;
1091 }
1092
1093 export function isBrowser() {
1094   return typeof window !== 'undefined';
1095 }
1096
1097 export function setAuth(obj: any, auth: string) {
1098   if (auth) {
1099     obj.auth = auth;
1100   }
1101 }
1102
1103 export function setIsoData(context: any): IsoData {
1104   let isoData: IsoData = isBrowser()
1105     ? window.isoData
1106     : context.router.staticContext;
1107   return isoData;
1108 }
1109
1110 export function wsSubscribe(parseMessage: any): Subscription {
1111   if (isBrowser()) {
1112     return WebSocketService.Instance.subject
1113       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
1114       .subscribe(
1115         msg => parseMessage(msg),
1116         err => console.error(err),
1117         () => console.log('complete')
1118       );
1119   } else {
1120     return null;
1121   }
1122 }
1123
1124 moment.updateLocale('en', {
1125   relativeTime: {
1126     future: 'in %s',
1127     past: '%s ago',
1128     s: '<1m',
1129     ss: '%ds',
1130     m: '1m',
1131     mm: '%dm',
1132     h: '1h',
1133     hh: '%dh',
1134     d: '1d',
1135     dd: '%dd',
1136     w: '1w',
1137     ww: '%dw',
1138     M: '1M',
1139     MM: '%dM',
1140     y: '1Y',
1141     yy: '%dY',
1142   },
1143 });